npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 migrate

The 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/session

Which 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?}. role defaults to user (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 → always superadmin.

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 on

The 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 in

The 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 register

Creating 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 in

Who 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 login

Every 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 denyDeviceAuth

There 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 info and approve answer 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_authorizations does 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/O or 1/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 pending and nowhere else, so a second approval, a deny after an approve, or two approvals racing each other all get DeviceAuthAlreadyHandledError (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: start and poll per IP, info / approve / deny per 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/login does. Approval and collection are separate moments, and what the account is when the key is registered is what counts.
  • start bounds what it stores. It is the one route that takes key material from a caller who cannot authenticate, so publicKey, keyId and fingerprint carry 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 without waitMillis is — 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 expiresAtMillis in 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 off

Every 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 } });   // everything

All three key-management operations are POST with their arguments in the body, deliberately. The mobile auth profile (clientProofV1) signs the request body, and canonical-json fixes exactly how those bytes are written. A GET has 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, bodyless core.time synchronization prerequisite is the explicit exception.

  • A key must be the type its algorithm names. A P-256 SPKI declared RS256, an RSA key declared ES256, and a curve other than P-256 declared ES256 are each refused 400 with KeyAlgorithmMismatchError on 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.
  • isExpired is computed, not stored. Nothing flips isActive when the TTL runs out — authenticate refuses 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 logout already does. selfRevoked in the response tells the two cases apart.
  • revokeAllKeys spares the calling device unless you ask otherwise, so the common case is "sign out my other devices". includeCurrent: true is 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. revokedCount still 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. authenticate reads the key from the database on every request with no cache in front of it.
  • includeRevoked: true shows what was already cut off, with revokedAt. 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.

  • registeredIp and registeredUserAgent are 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 string unknown is 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.
  • binding says 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.
  • concurrentUseAtMillis is when this key was last seen from two addresses at once, inside SPFN_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. GET mints 32 random bytes, sets them in a cookie scoped to the page's own path (HttpOnly, Secure in production, SameSite=Strict, 15 minutes) and mirrors them into the form; POST compares the two before calling the API and expires the cookie afterwards. A custom render must echo view.fields and view.csrfToken back 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 url carries 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 revokeAllKeys route 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.
  • ttlMinutes must be a positive whole number. Zero or negative is a ValidationError and writes no row; an unknown userId is 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 of authJobRouter: 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 / revoke

A 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.com

With 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, and localhost is the one host a browser treats as a secure context over plain http — so http://localhost:3000 is legal and http://app.example.com is 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 currentPassword and 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 credentialId nor 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.
  • deviceType is singleDevice or multiDevice, and backedUp says 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/options takes an empty body — additionalProperties: false, so an email field is a 400 rather than something quietly ignored — and always answers with an empty allowCredentials. 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 warn with 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 |