threshold1

Passkey-first & smart fallback

threshold1's login()/register() are built around one flagship pattern: try a passkey first, silently and automatically fall back to something else when a passkey isn't available. auth.fallback controls how aggressively that fallback happens, and "smart" — the default — adds a specific, deliberate mechanism for the no-email case: recognizing a device without ever silently logging it in.

The three-state model

Call login() with no email and fallback: "smart" (the default), and the browser shows a passkey picker with no identifier required. What happens next depends on which of three states the attempt lands in.

State A — unknown device

No passkey succeeded, and there's nothing else to go on: no stored device-recognition token, or the server doesn't recognize the one that's there. login() resolves to:

{ "status": "identifier_required" }

Show an email input. This is the ordinary, most common outcome for a first-time visitor.

State B — recognized device

No passkey succeeded, but this browser has a device-recognition token from a previous successful login, and the server confirms it's tied to an account with a fallback method enrolled. login() resolves to:

{ "status": "confirm_required", "channel": "otp", "maskedIdentifier": "h••••••@gmail.com" }

This is never silent. Being recognized is not the same as being logged in — the SDK will not send anything on its own. Show a one-tap "Send code to h••••••@gmail.com?" prompt, and only call confirmDeviceSend() in direct response to the user actually tapping it:

// Only call this from a real click handler, never automatically —
// this is the hard rule State B exists to enforce.
async function onConfirmTap() {
  const { maskedIdentifier } = await auth.confirmDeviceSend();
  // show a code-entry field
}

async function onCodeSubmit(code) {
  const user = await auth.verifyDeviceCode(code);
}

The real identifier the code goes to is never sent to or known by the client at any point — the server resolves it from the device token alone. The client only ever sees the masked version.

State C — known identifier

You called login(email) with an email. This skips device recognition entirely and runs the ordinary cascade: passkey → magic link → OTP, in that fixed order, stopping at the first success.

What the device token actually is

The mechanism behind State B is a device-recognition token, reissued after any successful passkey-adjacent authentication — loginWithPasskey(), loginDiscoverable(), verifyDeviceCode() itself, a magic-link redirect picked up by resumeSession(), and a few others. It is stored in localStorage, not an httpOnly cookie, and that's a deliberate choice: this SDK runs on third-party sites, so a cookie set by threshold1's own API domain would be a third-party cookie — Safari and Firefox block those by default, which would make State B silently unavailable for a large share of real users. localStorage works everywhere the SDK does.

The tradeoff is that the stored value is technically readable by any JavaScript running on the host page. That's an acceptable tradeoff because the token itself is opaque, signed, and PII-free — reading it reveals nothing about the account it belongs to, and it can't be forged without the server's own signing secret, which every server-side check re-verifies. Losing access to storage (private browsing, quota limits, a user clearing site data) just means the next visit degrades to State A — a normal, fully supported outcome, not a failure state.

auth.fallback modes

const auth = new Threshold1({
  apiKey: "th_test_...",
  auth: { fallback: "smart" }, // "manual" | "automatic" | "smart" (default)
});
  • "manual" — never auto-cascades. A failed passkey attempt (with an identifier) or a failed discoverable attempt (without one) is thrown immediately for you to handle yourself. Device recognition is never consulted — no State B, ever.
  • "automatic" — cascades passkey → magic → otp when an identifier is known, same order as "smart". Without an identifier, behaves like "manual" for the entry point itself — there's no identifier to cascade to, and device recognition is still never consulted, so a failed discoverable attempt always surfaces as identifier_required, never confirm_required.
  • "smart" (default) — identical cascade to "automatic" when an identifier is known. Without one, this is the only mode that runs the three-state model above.

Domain setup the full cascade actually needs

sendOtp()/verifyOtp() work with nothing beyond an API key (see the Quickstart). The passkey and magic-link steps of the cascade above do not — each needs a one-time dashboard configuration step before it will succeed, in Authentication → Allowed Origins and Authentication → Passkey RP Domain.

Passkey needs a real domain, fail-closed

Every passkey call is resolved against the project's configured domain, and this is fail-closed with no exceptions for local development:

  • A test key (th_test_...) requires Test Domain to be set — without it, every passkey call throws "This project has no test_domain configured. Set one in the dashboard before using a 'test' key for passkey flows."
  • A live key (th_live_...) requires Primary Domain — same fail-closed behavior, different error if it's missing.

localhost, 127.0.0.1, and raw IP addresses are categorically rejected as domain values — you cannot enter them as a Test Domain or Primary Domain at all. This isn't a missing feature; it's an explicit validation rule. For local passkey testing before you have a real domain, use a tunnel (the dashboard's own placeholder is abc123.ngrok-free.dev) and set that as your Test Domain. Once you have a real deployed domain, set it as your Primary Domain — verification via a .well-known file is available and adds a trust signal, but is optional; passkeys work against an unverified domain immediately.

Magic link needs your origin in Allowed Origins

Separately, the redirectUrl you pass to register()/login() must have its origin listed in the project's Allowed Origins — the same list the dashboard also uses for passkey origin checks, despite being labeled "Passkey Origins" in the UI. Unlike Test Domain, localhost is explicitly fine here — add http://localhost:3000 (or whatever port you're on) directly.

What this means in practice, on localhost

If you're following along on a plain http://localhost dev server with no tunnel: passkey will fail every time (no way around it without a tunnel), magic link will fail until you add your origin to Allowed Origins, and only OTP works with zero setup. That's exactly why the Quickstart uses sendOtp()/verifyOtp() as its first working example — this page is what you need once you're ready to bring the full cascade online.

What's next

  • Environments covers how test/live keys isolate data and billing.
  • External user ID bridging covers how accounts resolve across every method, including the cascade above.