@spfn/auth
v0.3.0-beta.27
Published
Authentication for a Next.js full-stack app: sessions, social login, OAuth, RBAC and admin roles, without assembling an auth stack yourself
Maintainers
Readme
@spfn/auth
Two applications' worth of auth, in one package
Nothing ships until people can sign in. @spfn/auth clears that gate twice over — once
for the people who use your product, and once for the people who operate it.
- For your users — registration, password and OTP login, social sign-in, sessions, registered devices, and account deletion with a recovery window.
- For your operators — admin accounts seeded from the environment, roles and permissions enforced on every route, invitations, and role administration your superadmins can change at runtime.
The second half is what usually becomes a second application: an admin dashboard with its
own auth, its own screens, and its own maintenance, growing for as long as the product
does. Attach @spfn/mcp instead and those operations become tools an
AI agent runs, gated by the same roles — see
Can I operate the app without building an admin dashboard?.
Underneath: asymmetric client-signed JWTs (ES256/RS256), OTP verification, OAuth 2.0
through a pluggable provider registry (Google, GitHub, Kakao and Naver built in), session
cookies for Next.js, and runtime RBAC. Routes mount under /_auth/* and are reached
through a typed authApi client. Requires @spfn/core; Next.js is an optional peer
(^16.3.3).
Install
pnpm add @spfn/auth [email protected]@simplewebauthn/server and @simplewebauthn/browser come along as dependencies —
passkeys need them, and standards conformance is the whole risk there.
The browser half is bundled into the ./client entry rather than marked external, so nothing
in your app has to know about it.
Import paths
Entry points (from package.json exports). Picking the wrong one breaks the build —
/server, /client-proof and /nextjs/* pull in Node code and must never reach the browser bundle.
import { authApi, authRouteMap } from '@spfn/auth'; // isomorphic: client + route map + types/constants
import { authRouter, authenticate } from '@spfn/auth/server'; // SERVER ONLY: router, services, repos, middleware, helpers
import { /* hooks/components */ } from '@spfn/auth/client'; // browser only (currently empty — WIP)
import { env, envSchema } from '@spfn/auth/config'; // validated env proxy + schema
import { InvalidCredentialsError } from '@spfn/auth/errors'; // error classes + authErrorRegistry
import '@spfn/auth/nextjs/api'; // SERVER: auto-registers RPC interceptors (side-effect)
import { RequireAuth, getSession } from '@spfn/auth/nextjs/server'; // SERVER: RSC guards, session helpers, OAuth handler
import { OAuthCallback } from '@spfn/auth/nextjs/client'; // 'use client' OAuth callback component
import { createClientProofDevHandler } from '@spfn/auth/client-proof'; // SERVER: mobile clientProofV1 profile (see below)Database entities (
users,userPublicKeys, …) and all services/repositories are exported from@spfn/auth/server, not from the root@spfn/auth.
How do I add auth to an SPFN app?
Four edits in the consuming app. All four are required for the flow to work end to end.
1. Lifecycle — server.config.ts
createAuthLifecycle() validates env before DB connect, then seeds admin accounts and
initializes RBAC after the DB is ready. Pass custom roles/permissions here (see RBAC below).
import { defineServerConfig } from '@spfn/core/server';
import { createAuthLifecycle } from '@spfn/auth/server';
import { appRouter } from './router';
export default defineServerConfig()
.port(8790)
.routes(appRouter)
.lifecycle(createAuthLifecycle())
.build();2. Router + global middleware — router.ts
authRouter (the package's mainAuthRouter) is merged via .packages(); authenticate is
applied globally via .use(). Public routes opt out per-route with .skip(['auth']).
import { defineRouter } from '@spfn/core/route';
import { authRouter, authenticate } from '@spfn/auth/server';
import { getStatus } from './routes/status';
export const appRouter = defineRouter({
getStatus,
// ...your routes
})
.packages([authRouter]) // mounts /_auth/* and exposes routes on authApi
.use([authenticate]); // global auth middleware
export type AppRouter = typeof appRouter;3. Next.js interceptor — RPC proxy route
The interceptor handles session cookies, JWT signing, and key management automatically. Import it for its side-effect (it self-registers); it must run before the proxy is created.
// app/api/rpc/[routeName]/route.ts
import '@spfn/auth/nextjs/api'; // side-effect: registers auth interceptors
import { createRpcProxy } from '@spfn/core/nextjs/server';
import { routeMap } from '@/generated/route-map';
export const { GET, POST } = createRpcProxy({ routeMap });No auth route map is merged: the generated routeMap carries the routes of every package
router the app router mounts with .packages(), authRouter's included. authRouteMap is
still exported and { ...routeMap, ...authRouteMap } is still harmless — the two hold the
same entries — but it is a no-op.
4. Run migrations
pnpm spfn db generate # only if entities changed
pnpm spfn db migrateThe API client needs no auth-specific config. authApi is also available standalone:
import { authApi } from '@spfn/auth';
const session = await authApi.getAuthSession.call({}); // → GET /_auth/sessionWhich environment variables do I need?
Set across two files by audience. Server-only secrets go in .env.server; values the
Next.js runtime needs (session cookie crypto) go in .env.local. Names only below — supply
real secret values out of band, never commit them.
| Var | File | Required | Notes |
|-----|------|----------|-------|
| DATABASE_URL | both | yes | Postgres connection |
| SPFN_AUTH_VERIFICATION_TOKEN_SECRET | .env.server | yes | OTP / verification token signing |
| SPFN_AUTH_SESSION_SECRET | .env.local | yes | ≥32 chars, AES-256 session cookie encryption (validated: entropy/unique-char checks) |
| SPFN_AUTH_TOKEN_ENCRYPTION_KEYS | .env.server | web OAuth, MFA | At-rest keyring: comma-separated <keyId>:<base64-32-byte-key> entries; first key is active. Required by any app offering a second factor, social login or not |
| SPFN_API_URL | .env.local | — | default http://localhost:8790 |
| SPFN_AUTH_SESSION_TTL | both | — | default 7d (e.g. 7d, 12h, 45m) |
| SPFN_AUTH_JWT_SECRET / SPFN_AUTH_JWT_EXPIRES_IN | .env.server | — | legacy server-signed JWT mode only |
| SPFN_AUTH_BCRYPT_SALT_ROUNDS | .env.server | — | default 12 (native bcrypt, off the event loop) |
| SPFN_AUTH_COOKIE_SECURE | both | — | override Secure flag (defaults to NODE_ENV==='production') |
| SPFN_AUTH_CSRF | .env.local | — | off | warn | enforce; unset behaves as warn — see CSRF protection |
| SPFN_AUTH_ADMIN_* | .env.server | — | admin seeding (see below) |
| SPFN_AUTH_GOOGLE_CLIENT_ID / _CLIENT_SECRET | .env.server | — | enables Google OAuth when both set |
| SPFN_AUTH_GOOGLE_SCOPES | .env.server | — | comma-separated; default email,profile |
| SPFN_AUTH_GOOGLE_REDIRECT_URI | .env.server | — | default {NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/google/callback; an override must stay on the web app origin at that path and is checked at boot — see OAuth callback origin |
| SPFN_AUTH_KAKAO_CLIENT_ID / _CLIENT_SECRET | .env.server | — | REST API key enables Kakao Login; secret is included when configured |
| SPFN_AUTH_KAKAO_ADMIN_KEY | .env.server | — | app admin key; required to verify the Kakao User Unlinked webhook |
| SPFN_AUTH_KAKAO_SCOPES / _REDIRECT_URI | .env.server | — | default scope account_email; callback /_auth/oauth/kakao/callback on the web app origin, checked at boot — see OAuth callback origin |
| SPFN_AUTH_NAVER_CLIENT_ID / _CLIENT_SECRET | .env.server | — | both values enable Naver Login |
| SPFN_AUTH_NAVER_REDIRECT_URI | .env.server | — | default {NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/naver/callback; an override must stay on the web app origin at that path and is checked at boot — see OAuth callback origin |
| SPFN_AUTH_GITHUB_CLIENT_ID / _CLIENT_SECRET | .env.server | — | both values enable GitHub OAuth |
| SPFN_AUTH_GITHUB_SCOPES / _REDIRECT_URI | .env.server | — | default scopes read:user,user:email; callback /_auth/oauth/github/callback on the web app origin, checked at boot — see OAuth callback origin |
| SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK | .env.server | — | off disables the boot check of the four _REDIRECT_URI overrides; any other value (unset included) runs it — see OAuth callback origin |
| SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS | .env.server | — | comma-separated client IDs accepted as native id_token audience (iOS/Android/web); enables Google native sign-in |
| SPFN_AUTH_APPLE_CLIENT_IDS | .env.server | — | comma-separated Apple client IDs (bundle ID / Services ID); enables Apple native sign-in |
| SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS | .env.server | — | comma-separated Kakao app keys accepted as native id_token audience (native app key); SPFN_AUTH_KAKAO_CLIENT_ID is also accepted, so either one enables Kakao native sign-in |
| SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS | .env.server | — | comma-separated Naver client IDs accepted as native id_token audience. SPFN_AUTH_NAVER_CLIENT_ID is also accepted, so this is only needed for a separate app application |
| SPFN_AUTH_OAUTH_SUCCESS_URL | .env.server | — | default /auth/callback |
| SPFN_AUTH_OAUTH_ERROR_URL | .env.server | — | default /auth/error?error={error} |
| SPFN_AUTH_RESERVED_USERNAMES / _USERNAME_MIN_LENGTH / _USERNAME_MAX_LENGTH | .env.server | — | username rules |
| SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES / _SETUP_TTL_MINUTES | .env.server | — | defaults 30 / 15 — see Verified-email signup |
| SPFN_AUTH_SIGNUP_CONFIRM_PATH | .env.server | — | default /signup/confirm; the page in your app the emailed link opens |
| SPFN_AUTH_PASSWORD_RESET_LINK_TTL_MINUTES / _SETUP_TTL_MINUTES | .env.server | — | defaults 30 / 15 — see Password reset |
| SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH | .env.server | — | default /password/reset; the page in your app the emailed link opens |
| SPFN_AUTH_REVOKE_ALL_LINK_TTL_MINUTES | .env.server | — | default 30 — see The sign-out-everywhere link |
| SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH | .env.server | — | default /account/revoke-all; the page in your app the link opens |
| SPFN_AUTH_LINK_MAIL_DELIVERY | .env.server | — | auto (default) | inline | queued; who sends signup-link, reset and account-exists mail — see Link mail delivery |
| SPFN_AUTH_PASSKEY_RP_ID / _RP_NAME / _ORIGINS | .env.server | — | relying party for passkeys; defaults derive from {NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL} and are checked at boot — see Passkeys |
| SPFN_AUTH_PASSKEY_USER_VERIFICATION | .env.server | — | preferred (default) or required; discouraged refuses boot |
| SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS / _RECENT_AUTH_MINUTES | .env.server | — | defaults 300 / 10 — see Passkeys |
| SPFN_AUTH_MFA_ISSUER | .env.server | — | name the authenticator app files the account under; defaults to the passkey relying-party name, then the app URL host — see Second factor |
| SPFN_AUTH_MFA_STEP_UP_MINUTES | .env.server | — | default 10; how recently an enrolled account's device must have proved its second factor for a sensitive change — see Second factor |
| SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES | .env.server | — | default 10; how long a new-device step-up challenge stays spendable — see Step-up on a new device |
| SPFN_AUTH_MFA_CONFIRM_PATH | .env.server | — | default /auth/mfa; app page the OAuth callback handler sends a browser to when a social sign-in needs a second factor |
| SPFN_AUTH_BOUND_KEY_TTL_HOURS | .env.server | — | default 24; how long a passkey-bound session key lives — see Session binding |
| SPFN_AUTH_BOUND_KEY_RENEW_GRACE_HOURS | .env.server | — | default 168; how long past expiry a bound key may still be renewed. Past it, sign in again |
| SPFN_AUTH_CONCURRENT_USE_WINDOW_MS | .env.server | — | default 300000; how close two sightings from two addresses must be to raise concurrentUseAtMillis |
| SPFN_AUTH_SESSION_RENEW_PATH | .env.local | — | default /auth/renew; the page RequireAuth sends a bound session whose key ran out |
| NEXT_PUBLIC_SPFN_API_URL / NEXT_PUBLIC_SPFN_APP_URL | .env.local | — | browser-facing URLs for OAuth redirects |
Read validated values via import { env } from '@spfn/auth/config' (a proxy validated at
startup). envSchema carries descriptions/defaults.
Admin seeding
createAuthLifecycle() creates admin accounts on startup from env, in priority order. Seeded
accounts are auto email-verified, status: 'active', passwordChangeRequired: true.
- JSON (recommended):
SPFN_AUTH_ADMIN_ACCOUNTS— array of{email, password, role?, phone?, passwordChangeRequired?}.roledefaults touser(user|admin|superadmin). - CSV:
SPFN_AUTH_ADMIN_EMAILS+SPFN_AUTH_ADMIN_PASSWORDS+SPFN_AUTH_ADMIN_ROLES. - Single (legacy):
SPFN_AUTH_ADMIN_EMAIL+SPFN_AUTH_ADMIN_PASSWORD→ alwayssuperadmin.
Routes
All routes mount at /_auth/* and are reached through authApi.<name>.call({ body }). Public
routes use .skip(['auth']); the rest require Authorization: Bearer <client-signed-jwt>.
| authApi method | HTTP | Auth | Purpose |
|------------------|------|------|---------|
| sendVerificationCode | POST /_auth/codes | public | send 6-digit OTP |
| verifyCode | POST /_auth/codes/verify | public | verify OTP → verification token |
| register | POST /_auth/register | public | create user + register public key |
| requestSignupLink | POST /_auth/signup/email | public | email a one-time signup confirmation link — see Verified-email signup |
| confirmSignupLink | POST /_auth/signup/email/confirm | public | exchange the link for a password-setup session |
| completeSignup | POST /_auth/signup/password | setup session | set the password, which creates the account and signs in |
| requestPasswordReset | POST /_auth/password/reset | public | email a one-time password reset link — see Password reset |
| confirmPasswordReset | POST /_auth/password/reset/confirm | public | exchange the link for a password-setup session |
| completePasswordReset | POST /_auth/password/reset/complete | setup session | set the new password, sign every other device out, sign this one in |
| login | POST /_auth/login | public | password login + new session key |
| startDeviceAuth | POST /_auth/device/start | public | begin a device-code login — see Device-code login |
| pollDeviceAuth | POST /_auth/device/poll | public | ask whether the request was answered; the approved answer is the login |
| getDeviceAuthInfo | POST /_auth/device/info | yes | what device is asking, so the approval screen can show it |
| approveDeviceAuth | POST /_auth/device/approve | yes | let the waiting device in |
| denyDeviceAuth | POST /_auth/device/deny | yes | refuse it |
| passkeyRegisterOptions | POST /_auth/passkeys/register/options | yes | begin enrolling a passkey — see Passkeys |
| passkeyRegisterVerify | POST /_auth/passkeys/register/verify | yes | verify the attestation and keep the credential |
| passkeyLoginOptions | POST /_auth/passkeys/login/options | public | begin a passkey sign-in; takes no identifier |
| passkeyLoginVerify | POST /_auth/passkeys/login/verify | public | verify the assertion; answers exactly as login |
| listPasskeys | POST /_auth/passkeys/list | yes | the caller's enrolled passkeys |
| renamePasskey | POST /_auth/passkeys/rename | yes | rename one |
| revokePasskey | POST /_auth/passkeys/revoke | yes | retire one (refused if it is the last way in) |
| mfaTotpEnroll | POST /_auth/mfa/totp/enroll | yes | mint a TOTP secret — see Second factor |
| mfaTotpConfirm | POST /_auth/mfa/totp/confirm | yes | spend the first code; answers the ten recovery codes |
| mfaDisable | POST /_auth/mfa/disable | yes + step-up | remove the second factor (204 either way) |
| mfaMarkPasskey | POST /_auth/mfa/passkey/mark | yes + step-up | mark or unmark a passkey as the second factor |
| mfaRegenerateRecoveryCodes | POST /_auth/mfa/recovery/regenerate | yes + step-up | ten fresh codes; every earlier one stops verifying |
| mfaStatus | GET /_auth/mfa/status | yes | { enrolled, methods, recoveryCodesRemaining }; no secret |
| mfaStepUp | POST /_auth/mfa/step-up | yes | re-prove the second factor on this device |
| mfaStepUpOptions | POST /_auth/mfa/step-up/options | yes | options for a step-up by passkey |
| mfaVerify | POST /_auth/mfa/verify | public | finish a sign-in that answered 202 { mfaRequired: true } — see Step-up on a new device |
| mfaVerifyOptions | POST /_auth/mfa/verify/options | public | options for finishing that sign-in with a passkey |
| logout | POST /_auth/logout | yes | revoke current key |
| rotateKey | POST /_auth/keys/rotate | yes | rotate public key before 90-day expiry |
| listKeys | POST /_auth/keys/list | yes | the caller's registered devices — see Registered devices |
| revokeKey | POST /_auth/keys/revoke | yes | sign one device out |
| revokeAllKeys | POST /_auth/keys/revoke-all | yes | sign every device out (spares the caller by default) |
| setSessionBinding | POST /_auth/session/binding | yes | turn session binding on or off — see Session binding |
| getSessionBinding | GET /_auth/session/binding | yes | whether it is on, and when this session's key expires |
| sessionBindingDisableOptions | POST /_auth/session/binding/disable/options | yes | the challenge that proves it is you before turning it off |
| sessionRenewOptions | POST /_auth/session/renew/options | public | begin renewing a bound session key |
| sessionRenewVerify | POST /_auth/session/renew/verify | public | verify the assertion; answers exactly as login |
| changePassword | PUT /_auth/password | yes | change password |
| getAuthSession | GET /_auth/session | yes | current session/user |
| issueOneTimeToken | POST | yes | short-lived token (e.g. SSE handshake) |
| checkUsername / updateUsername / updateLocale | — | mixed | username availability/update, locale |
| getUserProfile / updateUserProfile | — | yes | profile read/update |
| createInvitation / acceptInvitation / listInvitations / cancelInvitation / resendInvitation / deleteInvitation / getInvitation | — | mixed | invitation flow |
| requestAccountDeletion | POST /_auth/deletion/request | yes | request account deletion (re-auth gated) — see Account Deletion & Recovery |
| cancelAccountDeletion | POST /_auth/deletion/cancel | public | cancel a pending deletion (credential-based recovery) |
| listRoles / createAdminRole / updateAdminRole / deleteAdminRole / updateUserRole | — | superadmin | admin RBAC management |
| OAuth routes | — | — | see OAuth section |
| registerOAuth2Client / getOAuth2Authorize / createOAuth2AuthorizationCode / oauth2Token / oauth2Revoke / listOAuth2Grants / revokeOAuth2Grant | /_auth/oauth2/* | mixed | OAuth 2.1 authorization server for MCP clients — see Authorization server for MCP clients. 404 unless configured |
There is deliberately no account-existence endpoint. POST /_auth/exists was removed
because it answered "does this account exist" directly, which is user enumeration; the
login path is timing-equalized for the same reason. Do not reintroduce one without
revisiting that decision.
Auth uses asymmetric, client-signed JWTs: the client generates an ES256/RS256 keypair,
sends the public key on register/login, signs request JWTs locally, and the server verifies
with the stored public key (keyId carried in the JWT). The server never holds a private key.
Keys expire after 90 days — rotate with rotateKey, which starts the ninety days again. A key
bound to a passkey is the one exception: it lives for hours and a rotation carries its expiry over
rather than resetting it, because only session/renew may move that window — see
Session binding.
Migration — narrow a sign-in on mfaRequired before reading userId
Breaking in @spfn/auth 0.3.0-beta.25 / mobile contract 0.13.0. A sign-in no
longer always answers with a session. An account that enrolled a second factor and
signs in from a device the account has never seen gets 202 and a challenge
instead, and the key it registered stays inactive until that challenge is spent —
see Second factor.
So LoginResult carries one new required field, mfaRequired, and every field it
carried before is now optional. It is still one type rather than a union:
authApi.login infers its result from that declaration, and a union would make
every result.userId in your app a compile error with no way to narrow it that
was available in 0.12.x. Narrow on the discriminant:
const result = await authApi.login.call({ body: { email, password } });
if (result.mfaRequired)
{
// No session yet. result.challenge is { secret, expiresAtMillis }.
router.push('/auth/mfa');
return;
}
console.log(result.userId); // string, from here onThe same reshape applies to authApi.oauthNative (OauthNativeResult), to
completePasswordReset, and to the approved branch of pollDeviceAuth — which
carries mfaRequired: false and can never carry anything else, since a
device-code approval is itself a second factor.
Nothing changes for an account with no second factor: every one of those calls
answers 200 with mfaRequired: false and exactly the fields it always did.
In the Next.js proxy nothing changes for your code at all — the interceptors
handle the 202 and the pending cookie themselves.
Verified-email signup
A second way in, alongside the six-digit code. The address is proven before a password exists, so nothing is stored for someone who never confirms.
request → a one-time link is emailed
confirm → the link becomes a short-lived, HttpOnly password-setup session
password → the account is created, the device registered, the user signed inThe six-digit-code path (sendVerificationCode → verifyCode → register) is unchanged.
Offer whichever suits your product, or both.
1 — request the link. The response is identical whether or not the address already has an account, so it cannot be used to probe for accounts. When one exists, the owner gets a "you already have an account" notice instead of a usable link.
await authApi.requestSignupLink.call({
body: { email: '[email protected]', returnPath: '/welcome' }, // returnPath optional
});
// → { success: true, expiresAt }Calling it again is how a resend works: it invalidates the previous link and any setup
session opened from it. returnPath must be a path inside your app — absolute URLs,
//host, and .. are refused, so the link cannot become an open redirect.
The mail leaves through the auth.link-mail job when pg-boss is initialised — register
authJobRouter — so neither branch of this endpoint waits on a mail provider; see
Link mail delivery.
2 — the page the link opens. The email points at a page in your app
(SPFN_AUTH_SIGNUP_CONFIRM_PATH, default /signup/confirm), not at an API route. That page
reads the token from the query string and posts it:
'use client';
const token = useSearchParams().get('token');
const { email, returnPath } = await authApi.confirmSignupLink.call({ body: { token } });
// Drop the token from the URL so it does not linger in history or a Referer header.
window.history.replaceState({}, '', window.location.pathname);The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
and strips it from the response body, so page script never holds it. Serve this page with
Referrer-Policy: no-referrer.
3 — set the password. This is the step that creates the account. The setup cookie
authorizes it; the device keypair is injected by the interceptor exactly as it is for
register.
await authApi.completeSignup.call({ body: { password } });
// → { userId, publicId, email } + session cookie, same as registerCreating the user, registering the device key, and marking the setup session used all commit together. A password that fails the strength policy leaves the session usable, so the user retypes rather than requesting a fresh email.
Settings.
| Variable | Default | Meaning |
|----------|---------|---------|
| SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES | 30 | how long the emailed link works |
| SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES | 15 | how long the password-setup session works |
| SPFN_AUTH_SIGNUP_CONFIRM_PATH | /signup/confirm | the page in your app the link opens |
The link URL is built on NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL, the same resolution the
OAuth callbacks use. Delivery uses the signup-link template in @spfn/notification —
override it there to change the copy.
What is stored. Only SHA-256 hashes of the link token and the setup secret, in
spfn_auth.signup_link_tokens. Neither credential is recoverable from the database, and
both are one-time: a link opens one setup session, and a setup session sets one password.
Password reset (verified email)
The way back into an account whose password is gone, using the address the account already proved. Same three steps as the signup above, and the same posture on the two credentials.
request → a one-time link is emailed
confirm → the link becomes a short-lived, HttpOnly password-setup session
complete → the new password is written, every other device is signed out, this one is signed inWho can reset. An active account whose emailVerifiedAt is set or that already has
a password. The second half is what makes the rule work on accounts created before the
column was stamped: both register paths proved the address at signup. An OAuth-only account
whose provider reported the address unverified has neither and is excluded — for it, a reset
would be a way in built on an address nobody proved.
1 — request the link. The response is identical for every input — same status, same two
fields, same expiresAt arithmetic — and mail goes only to an account that can be reset, so
neither the answer nor the mailbox reveals whether an address has an account here.
await authApi.requestPasswordReset.call({
body: { email: '[email protected]', returnPath: '/account' }, // returnPath optional
});
// → { success: true, expiresAt }Calling it again is how a resend works: it invalidates the previous link and any setup
session opened from it. returnPath must be a path inside your app — absolute URLs,
//host, and .. are refused, so the link cannot become an open redirect.
The mail leaves through the auth.link-mail job when pg-boss is initialised — register
authJobRouter — so an address with an account and one without cost the same; see
Link mail delivery.
2 — the page the link opens. The email points at a page in your app
(SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH, default /password/reset), not at an API route.
That page reads the token from the query string and posts it:
'use client';
const token = useSearchParams().get('token');
const { email, returnPath } = await authApi.confirmPasswordReset.call({ body: { token } });
// Drop the token from the URL so it does not linger in history or a Referer header.
window.history.replaceState({}, '', window.location.pathname);The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
and strips it from the response body, so page script never holds it. It is a cookie of its
own, not the signup one, so neither secret is ever accepted by the other flow. Serve this
page with Referrer-Policy: no-referrer.
3 — set the new password. The setup cookie authorizes it; the device keypair is injected
by the interceptor exactly as it is for login.
await authApi.completePasswordReset.call({ body: { password } });
// → { userId, publicId, email } + session cookie, same as loginEvery other device is signed out. Completing a reset denies every pending device
authorization and revokes every active key, exactly as changePassword does — whoever was
signed in on the old password, including the person the reset was needed for, has to sign in
again. The browser that performed the reset is signed in on a fresh key registered after the
revocation, so it does not have to retype the new password. emailVerifiedAt is stamped if
it was not already, passwordChangeRequired is cleared, and auth.password.reset is emitted
after commit.
The new hash, the revocations, the new device key and the completion mark commit together. A password that fails the strength policy leaves the session usable, so the user retypes rather than requesting a fresh email.
Settings.
| Variable | Default | Meaning |
|----------|---------|---------|
| SPFN_AUTH_PASSWORD_RESET_LINK_TTL_MINUTES | 30 | how long the emailed link works |
| SPFN_AUTH_PASSWORD_RESET_SETUP_TTL_MINUTES | 15 | how long the password-setup session works |
| SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH | /password/reset | the page in your app the link opens |
The link URL is built on NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL, the same resolution the
signup link uses. Delivery uses the password-reset template in @spfn/notification —
override it there to change the copy.
What is stored. Only SHA-256 hashes of the link token and the setup secret, in
spfn_auth.password_reset_tokens. Neither credential is recoverable from the database, and
both are one-time: a link opens one setup session, and a setup session sets one password.
A separate table from signup_link_tokens, so a signup secret can never address a reset row.
Device-code login
A way in for a device that has a screen but no comfortable keyboard — a TV, a console, a CLI on a headless box. The new device shows a short code; the account owner types that code on a device that is already signed in.
// On the new device — it has no key on file, so this call is public.
const { deviceCode, userCode, expiresAtMillis, intervalMillis } =
await authApi.startDeviceAuth.call({ body: {
publicKey, keyId, fingerprint, algorithm: 'ES256',
deviceName: 'Living room TV', platform: 'desktop',
} });
// Show `userCode` (XXXX-XXXX) on this device's screen, then poll every intervalMillis.
const answer = await authApi.pollDeviceAuth.call({ body: { deviceCode } });
// → { status: 'pending', intervalMillis }
// → { status: 'approved', userId, publicId, email?, phone?, passwordChangeRequired }Or long-poll: send waitMillis and the server holds a pending request until the owner
answers or the wait runs out, so the device learns of an approval the moment it is made
instead of at its next tick.
let answer;
do
{
// Held up to 20s (the server's maxWaitMs caps it). A pending answer takes the time
// already waited off intervalMillis — 0 after a full wait, so ask again at once.
// An error ends the loop, as before.
answer = await authApi.pollDeviceAuth.call({ body: { deviceCode, waitMillis: 20_000 } });
if (answer.status === 'pending' && answer.intervalMillis > 0)
{
await new Promise(resolve => setTimeout(resolve, answer.intervalMillis));
}
}
while (answer.status === 'pending');Keep the loop's sleep on intervalMillis > 0. It covers a server that answered without
waiting — an older one that ignores the field — so the loop never spins.
// On the signed-in device — the user typed the code they read off the other screen.
const asking = await authApi.getDeviceAuthInfo.call({ body: { userCode } });
// → { deviceName?, platform?, fingerprintPrefix, requestedAtMillis, expiresAtMillis }
await authApi.approveDeviceAuth.call({ body: { userCode } }); // or denyDeviceAuthThere is no token handed over, because there is no token. Every request in this system is
signed by the calling device's own key, so "logging a device in" means getting its public key
into user_public_keys under the right account — which is exactly what the winning poll does.
That is why the approved answer is the same shape login returns: from the client's side the
two ways in are indistinguishable.
- Only ever show the code on the new device's screen. The whole attack on this flow is
someone sending a victim a code and asking them to approve it — a support call, a chat
message, a "verify your account" email. A code that arrived any way other than off the
device in front of you is an attack. This is why
infoandapproveanswer with the requesting device's name, platform and fingerprint prefix, and why an approval screen that shows only the code is doing it wrong: it is asking the user to confirm a number they were just told. - The device code is stored only as a SHA-256 hash, like the ops-token and signup-link
secrets. It is returned once. A dump of
spfn_auth.device_authorizationsdoes not let its reader finish anyone's login. - The user code is stored in the clear, and that is fine — it authorizes nothing without
an approver who is already signed in. It is drawn from an alphabet with no
0/Oor1/I/L, since it is read off one screen and typed on another. - A decision is made once. Approve and deny move the record from
pendingand nowhere else, so a second approval, a deny after an approve, or two approvals racing each other all getDeviceAuthAlreadyHandledError(409) — a refusal is never undone. - The approval is one-shot. The poll that registers the key spends the record in the same statement that reads it, so of two polls arriving together exactly one registers the key and the other is answered as if the code were unknown.
- A spent code and a code that never existed answer identically (
DeviceAuthNotFoundError, 404). Saying "that one was real, but it is used up" is the difference between guessing at random and knowing a guess landed. Every route that accepts a code is rate limited for the same reason:startandpollper IP,info/approve/denyper IP and per calling account. - Expiry outranks state. A code that sat past its TTL is expired whatever it says, so an approval nobody collected in time registers nothing. The TTL travels in the statement that moves the record, not only in the read before it, so a code cannot be spent by a poll that read it a moment before it died.
- A global revocation reaches the codes too.
revoke-all, a password change and a deletion request each refuse the account's live device authorizations, so an approval nobody collected cannot register a fresh key seconds after the user signed everything out — which would hand one back to exactly the device they were cutting off. Revoking a single key, logging out and rotating a key do not: those name one device, and the waiting one is not it. - The poll re-checks the account. It is a login, so it refuses a suspended or
pending-deletion account with the same errors
/_auth/logindoes. Approval and collection are separate moments, and what the account is when the key is registered is what counts. startbounds what it stores. It is the one route that takes key material from a caller who cannot authenticate, sopublicKey,keyIdandfingerprintcarry length limits — generous next to a real key (an RSA-2048 SPKI is 392 base64 characters against a 2048 limit) and small next to the megabyte that would otherwise sit in a table no job clears.- A long poll holds no transaction. The wait is route middleware in front of the poll's
Transactional(), so a waiting device does not pin a pooled connection, and the answer is judged inside the transaction exactly as a poll withoutwaitMillisis — same atomicity, same database-error answers. Approve, deny and a global revocation wake a poll parked in the same process after they commit. A poll parked on another instance re-reads its record every second, so an approval committed elsewhere reaches it within about a second. A device that hangs up mid-wait is not judged, so an approval it can no longer hear waits for its next poll; at most three polls wait on one code at a time — a fourth is answered at once; and a server that starts shutting down ends every wait with a pending answer rather than a cut connection. - Clock skew cannot affect this. Every timestamp in the decision is the server's. The
expiresAtMillisin the start response is for the waiting device's countdown display, and nothing the client believes about the time reaches the server's judgement.
Three knobs, resolved at lifecycle time rather than read per call — the first two are announced to the waiting device in the start response:
createAuthLifecycle({
deviceAuth: {
ttlMs: 10 * 60 * 1000, // how long a code lives. default 10 minutes
intervalMs: 5000, // poll interval the server asks for. default 5s
maxWaitMs: 20_000, // longest a long poll is held. default 20s
},
})Keep maxWaitMs under the idle timeout of every proxy and load balancer in front of the
server. A long poll cut off by one reaches the device as a network error, not as a pending
answer — Google Cloud's load balancer closes a backend request at 30 seconds by default.
No job sweeps the table. Rows are judged by expiresAt whenever they are read or moved, so a
stale row authorizes nothing; it only keeps its user code out of circulation, and 31⁸ codes do
not run out.
Registered devices (key management)
A passkey is not one of these keys: it is a credential that proves identity at sign-in, after which an ordinary device key is registered exactly as a password login registers one.
Keys are per-device, so a login never revokes the previous key and they accumulate on purpose.
listKeys / revokeKey / revokeAllKeys are what let the account owner see what accumulated and
cut off anything they no longer recognise.
A key still waiting on a second factor is in neither list. It cannot sign for anything, so it is not a device; and nobody signed it out, so it is not a revoked one either. A global revocation deletes it outright rather than revoking it.
const { keys } = await authApi.listKeys.call({ body: {} });
// → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
// lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis?,
// registeredIp?, registeredUserAgent?, binding?, concurrentUseAtMillis? }]
await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut offEvery moment is epoch milliseconds, not an ISO string — one representation across the whole
surface, so a generated Swift or Kotlin client reads an integer instead of choosing a date
formatter. This changed in mobile contract 0.5.0; an app still reading createdAt moves to
createdAtMillis.
algorithm is the KeyAlgorithm enum from contract 0.6.0 rather than a bare string — the routes
have always constrained it to those values, and the contract had been understating the server. The
declared values are the ones the server accepts and sends now: one can be added, and one can be
withdrawn for a weakness found later, so a generated client should be built to meet a value it does
not recognise rather than assume the set is closed.
await authApi.revokeKey.call({ body: { keyId } }); // → { keyId, selfRevoked }
await authApi.revokeAllKeys.call({ body: {} }); // other devices only
await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // everythingAll three key-management operations are POST with their arguments in the body, deliberately. The mobile auth profile (clientProofV1) signs the request body, and
canonical-jsonfixes exactly how those bytes are written. AGEThas no body to sign, and a value in the path has no such rule — client and server could disagree on the signed string over percent-encoding, a trailing slash, or a proxy rewrite alone, and the request would be refused with nothing in the logs naming the cause. Proof-bearing auth operations are shaped this way; the unproven, bodylesscore.timesynchronization prerequisite is the explicit exception.
- A key must be the type its algorithm names. A P-256 SPKI declared
RS256, an RSA key declaredES256, and a curve other than P-256 declaredES256are each refused 400 withKeyAlgorithmMismatchErroron register, login, rotate and device start — the algorithm is stored beside the key and read back at proof verification, so a mismatch accepted at enrollment would surface only once the device already believed it was enrolled. - The public key never leaves the server, and the fingerprint is truncated to 8 characters. The list exists to recognise a device and point at it; the full fingerprint is what a native sign-in sends as its nonce, not a label.
isExpiredis computed, not stored. Nothing flipsisActivewhen the TTL runs out —authenticaterefuses the key at request time. A list that showed such a key as simply active would report something the server does not act on.- Revoking your own key is allowed. It is this device's sign-out, which
logoutalready does.selfRevokedin the response tells the two cases apart. revokeAllKeysspares the calling device unless you ask otherwise, so the common case is "sign out my other devices".includeCurrent: trueis the full sign-out — until now reachable only as a side effect of changing a password, which nobody does for that reason.- It also refuses device-code approvals still in flight, in both modes, because an approved
code is a key that has not been handed out yet: the next poll would register a fresh active one
and undo the sign-out.
revokedCountstill counts keys only — a code nobody collected was never a session. See Device-code login. - A key id you do not own answers 404 (
KeyNotFoundError). Every lookup is scoped by user, so the answer is only ever "not yours" and reveals nothing about other accounts. - Revocation takes effect immediately.
authenticatereads the key from the database on every request with no cache in front of it. includeRevoked: trueshows what was already cut off, withrevokedAt. The default is only keys that can still sign.
Every path that registers a key (register, login, rotateKey, native OAuth) accepts optional
deviceName (≤64 chars) and platform (ios / android / web / desktop). Both are display
only — nothing is authorized by them — and both are absent on keys registered before they existed.
Rotation carries the replaced key's label over unless the client sends a new one.
registeredIpandregisteredUserAgentare where the device came from, captured once from the request that registered the key and never updated — a device that later signs requests from another network still shows the address it appeared from, which is what makes an entry the owner does not recognise recognisable. Both are absent when the request resolved neither and on keys registered before the columns existed; the literal stringunknownis never stored. They are unauthenticated display material, spoofable on any request that does not come through a verified proxy, so render them and decide nothing by them. Mobile contract 0.11.0.bindingsays the key is tied to a passkey, and is absent on every key that is not — which is every key on an account that did not turn session binding on. A bound key expires in hours and only a passkey assertion renews it.concurrentUseAtMillisis when this key was last seen from two addresses at once, insideSPFN_AUTH_CONCURRENT_USE_WINDOW_MS. Absent when that has never been observed, which is the ordinary state. A signal to show, never a refusal — addresses change legitimately — and the addresses themselves are never returned. Meaningful only where proxy-guard is configured. Mobile contract 0.12.0.
All three are in the mobile contract (0.4.1) as auth.keys.list / auth.keys.revoke /
auth.keys.revokeAll, so a generated mobile client reaches them the same way it reaches key
rotation.
A keyId is single-use for its lifetime: it is unique across all users and is never reissued
once revoked. A client that logs out, rotates, or is revoked must generate a fresh keypair and
keyId for its next sign-in — resending the old one is refused with
KeyIdAlreadyRegisteredError (409), on every path that registers a key. Re-registering a key that
is still active is the one
exception: it stays a no-op success, so repeated logins from the same device keep working, and an
expired-but-active key has its expiry extended by the sign-in that proved the identity again.
The sign-out-everywhere link
The key operations above all need a session, which is exactly what an owner who no longer trusts
the device in front of them does not want to use. createRevokeAllLink mints a one-time link your
app mails to the address the account has already proved; opening it signs every device out with no
session at all.
import { createRevokeAllLink } from '@spfn/auth/server';
const { url, expiresAt } = await createRevokeAllLink(userId); // default TTL 30 minutes
const short = await createRevokeAllLink(userId, { ttlMinutes: 10 });The link opens a page in your app (SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH, default
/account/revoke-all), not an API route — the same shape the signup and reset links use. That page
ships with the package: mount it in one route file and you are done.
// app/account/revoke-all/route.ts
import { createRevokeAllPageHandlers } from '@spfn/auth/nextjs/server';
export const { GET, POST } = createRevokeAllPageHandlers();GET reads the token out of the query string, calls confirmRevokeAllLink and draws the expiry,
the device count and one button; POST calls consumeRevokeAllLink and reports the count it
signed out. Every answer carries Cache-Control: no-store and
Content-Security-Policy: frame-ancestors 'none', the token appears in a hidden field and the API
body and nowhere else, and every 404 is the same screen with no reason on it. Pass
render: (view: RevokeAllPageView) => string to own the body at all three stages
(confirm / done / invalid) while the handler keeps the status, the headers and the fields.
- There is no session on this page, so the CSRF token is not derived from one.
GETmints 32 random bytes, sets them in a cookie scoped to the page's own path (HttpOnly,Securein production,SameSite=Strict, 15 minutes) and mirrors them into the form;POSTcompares the two before calling the API and expires the cookie afterwards. A customrendermust echoview.fieldsandview.csrfTokenback as hidden inputs, or the form it draws cannot be submitted.
An app that wants its own page can call the two endpoints directly instead — they are public, and this is what the handlers above do:
'use client';
const token = useSearchParams().get('token');
// Describing the link changes nothing at all, so a mail scanner that prefetches
// the page has not signed anybody out.
const { expiresAt, activeKeyCount } = await authApi.confirmRevokeAllLink.call({ body: { token } });
// The button.
const { revokedCount } = await authApi.consumeRevokeAllLink.call({ body: { token } });- Every refusal is the same 404 (
RevokeAllLinkError), with the same body: unknown, expired, already spent, superseded by a newer link, issued against a key generation that has since moved, or belonging to an account that is not active. Telling those apart would tell whoever holds a random value that it named something real. 404 rather than the 401 the password reset link answers with, because there is no credential here to have been wrong: the mailbox is the proof, and what arrives either names an outstanding link or names nothing. - The token travels in the request body, never in a path segment. The request logger records the path of every request, and so does whatever proxy sits in front of it.
- Your obligation, which this package cannot enforce: the returned
urlcarries the plaintext token, because this flow sends no mail of its own. Do not log it, do not persist it, do not put it in a job payload — hand it to the mail template and let it go. The package's other two links are minted inside the worker that sends them precisely so no caller ever holds one; this one cannot be. - It is one-time and generation-bound. Consuming it is a single statement, so two clicks
produce one sign-out and one 404. It also dies the moment anything else ends the account's key
generation — a completed password reset, a password change, a deletion request, or the
revokeAllKeysroute in either mode. - It does not change the password. Send it alongside a password reset link: this one ends the sessions, that one ends the credential that started them.
- Issuing again supersedes. A second link retires the first, so asking twice does not leave a spare capability in the mailbox.
ttlMinutesmust be a positive whole number. Zero or negative is aValidationErrorand writes no row; an unknownuserIdis refused explicitly rather than surfacing as a foreign-key 500.- Rate limited 10/minute per address across both endpoints, on one counter — valid and invalid tokens are not counted separately, which would be a way to tell them apart.
- Expired and spent rows are swept by
auth.revoke-all-token-purge(daily 06:00), part ofauthJobRouter: a week after expiry, a day after being spent or superseded.
Settings.
| Variable | Default | Meaning |
|----------|---------|---------|
| SPFN_AUTH_REVOKE_ALL_LINK_TTL_MINUTES | 30 | how long the link works |
| SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH | /account/revoke-all | the page in your app the link opens |
The link URL is built on NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL, the same resolution the other
two links use. Only the SHA-256 of the token is stored, in spfn_auth.key_revoke_all_tokens.
Neither route is in the mobile contract: both are answered for a browser on a page in your app, with no session and no client proof, and a generated mobile client has a session by definition.
Passkeys (WebAuthn)
A passkey is an optional additional credential on an account, alongside a password and a linked social account rather than in place of either. Enroll one from a session that already exists; sign in with it afterwards without typing an identifier at all.
enroll → register/options (session) → the browser mints a credential → register/verify
sign in → login/options (public) → the browser picks a credential → login/verify
manage → list / rename / revokeA passkey is not a device key. The assertion proves who is asking; the device key the
Next.js proxy registers right after it is what every later request is signed with, exactly as
after a password login. Nothing in clientProofV1, in the JWT path, or in
Registered devices changes because a session started
this way — a passkey sign-in produces the same LoginResult and the same key row as login.
Setup
# .env.server — nothing is required; these are the overrides
SPFN_AUTH_PASSKEY_RP_ID=example.com
SPFN_AUTH_PASSKEY_ORIGINS=https://app.example.com,https://admin.example.comWith neither set, the relying party is derived from {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}:
its host becomes the rpId and its origin becomes the single allowed origin. That is the whole
configuration for a one-origin app.
| Var | File | Notes |
|-----|------|-------|
| SPFN_AUTH_PASSKEY_RP_ID | .env.server | domain credentials are bound to — no protocol, no port. Default: the app URL's host. Changing it orphans every passkey already enrolled |
| SPFN_AUTH_PASSKEY_RP_NAME | .env.server | name the authenticator's own prompt shows. Default: the rpId |
| SPFN_AUTH_PASSKEY_ORIGINS | .env.server | comma-separated full origins allowed to run a ceremony. Default: the app URL's origin |
| SPFN_AUTH_PASSKEY_USER_VERIFICATION | .env.server | preferred (default) or required. discouraged refuses boot |
| SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS | .env.server | default 300 — one ceremony at the authenticator, not an abandoned tab |
| SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES | .env.server | default 10 — see the recent-authentication gate |
Two rules on those origins, checked at boot and refused with PasskeyConfigError:
- each origin must be
https, andlocalhostis the one host a browser treats as a secure context over plainhttp— sohttp://localhost:3000is legal andhttp://app.example.comis not; - each origin's host must be the rpId or a subdomain of it, because the browser will refuse the ceremony otherwise.
The check runs at initializeAuth, deliberately: every one of these values makes every
passkey operation fail, the drift is between environments, and the deploy that introduces it
is where it has to surface — not the first sign-in after it.
Boot is only refused for a configuration you wrote. If no SPFN_AUTH_PASSKEY_* variable
is set, the derived relying party can still be unusable — SPFN_APP_URL=http://192.168.1.5:3000
so a phone on the same network can reach your laptop, say, which is neither https nor
localhost. Refusing to start over a feature nobody asked for would take that app down to fix
something it does not use, so it is logged once instead and only a ceremony fails. Set any
passkey variable and the same configuration refuses to start. This is the posture
the OAuth callback origin check already takes.
Enrolling, from a Next.js client component
'use client';
import { authApi } from '@spfn/auth';
import { enrollPasskey, isPasskeySupported } from '@spfn/auth/client';
async function addPasskey()
{
const result = await enrollPasskey(authApi, { label: 'MacBook Touch ID' });
if (!result.ok)
{
// 'unsupported' | 'cancelled' | 'error' — 'cancelled' is not an error to show
return result.reason === 'cancelled' ? undefined : showError(result.reason);
}
showAdded(result.passkeyId, result.label);
}isPasskeySupported() is what decides whether to render the button at all.
Signing in, with conditional UI
The passkey appears in the browser's ordinary autofill dropdown. That needs an input whose
autocomplete ends in webauthn, and a signInWithPasskey call started when the form
renders, not on a click:
'use client';
import { useEffect } from 'react';
import { authApi } from '@spfn/auth';
import { isConditionalMediationAvailable, signInWithPasskey } from '@spfn/auth/client';
export function SignInForm()
{
useEffect(() =>
{
void (async () =>
{
if (!await isConditionalMediationAvailable()) return;
const result = await signInWithPasskey(authApi, { conditional: true });
if (result.ok) router.replace('/');
})();
}, []);
return (
<form>
<input name="email" autoComplete="username webauthn" />
<input name="password" type="password" autoComplete="current-password" />
</form>
);
}Where conditional mediation is missing, render a visible "Sign in with a passkey" button that
calls signInWithPasskey(authApi) instead.
Both helpers answer with a discriminated union and never throw a cancellation: a person
who dismisses the system sheet raises NotAllowedError, and so does a person whose
authenticator had nothing to offer — neither is an application error, and code that has to
tell them apart by re-reading error.name gets it wrong once and shows a red banner to
someone who simply changed their mind.
| result | meaning |
|--------|---------|
| { ok: true, ... } | signed in / enrolled; the rest of the object is the server's answer |
| { ok: false, reason: 'unsupported' } | this browser has no WebAuthn; nothing was sent to the server |
| { ok: false, reason: 'cancelled' } | the person dismissed the enrollment prompt |
| { ok: false, reason: 'no-credential' } | sign-in: the authenticator offered nothing, or the person dismissed it |
| { ok: false, reason: 'error', error } | anything else, with the original error attached |
The recent-authentication gate
Adding a credential is adding a way in, and removing one can lock an account. Both are refused unless the caller has recently proved themselves, in one of two ways:
- the device key this request is signed with was registered within
SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES— that is when this device last presented a credential, and it needs no new state; or - the body carries
currentPasswordand it verifies.
Otherwise: 403 with code: 'RECENT_AUTH_REQUIRED'. Branch on that code to prompt for the
password and retry — it is a stable field, not a message to match on.
An account with no password stored cannot satisfy the gate with a password, however plausible the value; it has to sign in again. The comparison still runs, against a dummy hash, so "no password on file" costs exactly what "wrong password" costs — otherwise response time becomes an oracle for which accounts are OAuth-only.
Managing passkeys
const { passkeys } = await authApi.listPasskeys.call({ body: {} });
// → [{ passkeyId, label, deviceType, backedUp, transports, createdAt, lastUsedAt }]
await authApi.renamePasskey.call({ body: { passkeyId, label: 'Old iPhone' } });
await authApi.revokePasskey.call({ body: { passkeyId } });- Neither
credentialIdnor the public key is ever returned. They are what an authenticator is addressed by; the list exists to let someone recognise a credential and point at it, which the label, the device type and the last-used moment do. deviceTypeissingleDeviceormultiDevice, andbackedUpsays whether a multi-device credential actually has been. "This one only exists on that phone" is what the owner needs before revoking the other entry.- Revocation is soft, and the credential id stays reserved for good. A credential someone
cut off can never be enrolled again — not on another account, and not on the same one
(
PasskeyAlreadyRegisteredError, 409). Re-enrolling means a fresh credential. - A passkey id you do not own answers 404. Every lookup is owner-scoped, so the answer is only ever "not yours".
- Renaming has no recent-authentication gate: a label is display only and nothing is authorized by it.
Recovery — read this before shipping a passkey-only sign-up
The ways back into an account are: a live passkey, a password, a linked social account, or a verified email address — the last one because Password reset can always give such an account a password back. Nothing else; support cannot restore an account that has none of the four.
That is why revoking the last live passkey is refused (409, code:
'LAST_RECOVERY_CREDENTIAL') when the account has no password, no linked social account and no
verified email. A phone-only account is the case that reaches it. The refusal is not
paternalism; it is the absence of an undo. Branch on that code to offer "set a password
first", "link an account first", or "confirm your email address first".
The same fact should shape your sign-up: an account created without a password, without an email and given one passkey has exactly one way in, and losing the device loses the account. Ask for a password, an address, or a social link before, or shortly after, the passkey.
How the ceremonies are kept honest
- Discoverable credentials only (
residentKey: 'required').login/optionstakes an empty body —additionalProperties: false, so anemailfield is a 400 rather than something quietly ignored — and always answers with an emptyallowCredentials. There is no input that could make its answer differ by whether an account exists. - A revoked credential and one that was never here answer identically on
login/verify. Anything else would say whether this account once had it. - Challenges are one-time database rows, spent by a single conditional
UPDATE. Two verifies arriving with the same challenge produce one winner and one refusal, across instances. A challenge is bound to its ceremony (registration/authentication) and, for enrollment, to the account that minted it. - A refusal leaves the challenge live. Spending happens inside the transaction that writes what it authorizes, so a failure rolls it back and the ceremony is retryable; only a success is unrepeatable.
- A signature counter that goes backwards refuses the sign-in and leaves the row alone. It
is the signal a cloned authenticator would produce — but a synced passkey reports 0 forever
and a restored device can hit it, so auto-revoking would lock people out on a false
positive. The refusal is logged at
warnwith the passkey id; a human decides what it meant. - Attestation is
none. Verifying an attestation statement would tell us which authenticator model was used and nothing about who is holding it.
Errors
| error | status | code | when |
|-------|--------|--------|------|
| PasskeyChallengeError | 401 | — | the challenge is unknown, expired, already spent, of the other ceremony, or of another account |
| PasskeyVerificationError | 401 | — | origin, rpId, signature or counter — and, on sign-in, an unknown or revoked credential |
| PasskeyNotFoundError | 404 | — | a passkey the caller does not own, or one already revoked |
| PasskeyAlreadyRegisteredError | 409 | — | that credential is on file for some account, revoked ones included |
| RecentAuthenticationRequiredError | 403 | RECENT_AUTH_REQUIRED | the session proved itself too long ago and carried no password |
| LastRecoveryCredentialError | 409 | LAST_RECOVERY_CREDENTIAL | revoking it would leave no way back in |
| PasskeyConfigError | boot | — | an origin off the rpId or not https, or an unsupported user-verification value |
Events
passkeyEnrolledEvent (auth.passkey.enrolled: userId, passkeyId, label?) and
passkeyRevokedEvent (auth.passkey.revoked: userId, passkeyId, reason) fire after
commit. authLoginEvent.provider gains 'passkey'. Subscribe to the first to tell the owner
a new way into their account appeared — which is what it is.
The case table
The behaviour above is asserted row by row in
src/__tests__/integration/passkeys.test.ts; each it is named for its row.
| row | situation | outcome |
|-----|-----------|---------|
| E1 | fresh session, no passkeys | 200, empty excludeCredentials |
| E2 | session key 11 min old, no password | 403 RECENT_AUTH_REQUIRED |
| E3 / E4 | 11 min old, correct / wrong password | 200 / 403 — byte-identical to E2 |
| E5 | no password on the account, 11 min old | 403; a password can never speak for it |
| E6 | valid attestation | 200; row written, challenge spent, event emitted |
| E7 / E8 |
