Install @threshold1/auth, initialize it, and get a real user logged in. This walks through email OTP — the one login method that works with zero extra dashboard setup, so you can see a real login before configuring anything else.
You need a project and an API key. Both come from the same step:
Creating that first project automatically generates a test-mode API key (th_test_...) and shows it to you once, in a "copy this key now" modal — it is never shown again. You don't need to visit a separate API Keys page to generate one; if you do visit it right after creating your first project, it will look like key creation is disabled. That's expected — the free plan includes one key, and project creation already used it.
Copy that key somewhere safe before continuing.
npm install @threshold1/auth
Create one Threshold1 instance and reuse it everywhere — don't construct it inside a component or a request handler.
import { Threshold1 } from "@threshold1/auth";
const auth = new Threshold1({
apiKey: "th_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
auth: {
fallback: "smart", // the real default — shown explicitly here for clarity
},
});
No baseUrl needed — the SDK talks to threshold1's hosted API by default. auth.fallback controls the higher-level register() / login() cascade (passkey → magic link → OTP), covered in Core Concepts. That cascade's passkey and magic-link steps each need a one-time domain setup in the dashboard before they'll succeed, so this Quickstart uses the two lower-level calls below instead — sendOtp() and verifyOtp() — which need nothing beyond the API key above.
await auth.sendOtp("user@example.com");
This queues a real email with a 6-digit code. It resolves once the email is queued — it doesn't wait for the code to be entered.
Collect the code from your own input (a 6-box code field, a single text input, whatever fits your UI), then:
const user = await auth.verifyOtp("user@example.com", "123456");
console.log(user);
// { id: "c3588a12-...", email: "user@example.com", externalUserId: null }
A successful call authenticates the SDK instance — auth.isAuthenticated is now true, and you can call:
await auth.getUser();
// { id: "c3588a12-...", email: "user@example.com", externalUserId: null }
await auth.getSession();
// { userId: "c3588a12-...", issuedAt: 1787524970, expiresAt: 1788129770 }
getSession()'s issuedAt / expiresAt are Unix timestamps in seconds — sessions from this flow last 7 days.
The session above lives in memory on the auth instance for as long as the page stays open — a plain reload does not restore it. resumeSession() exists for a different, specific case: picking up a token from a magic-link redirect URL. Call it on load if you ever add magic link as a method; for an OTP-only flow like this one, it will simply resolve to null since there's no magic-link token to find.
If you need the login to survive a reload, that's on you to build — store what getUser()/getSession() gave you and decide how you want to re-establish state on the next load.
register() / login(), the three-state device-recognition flow, and the domain setup passkey and magic link need.