--- title: Passkey (WebAuthn) Flow tags: [flow, auth, passkey, webauthn, passwordless] related_models: ["[[User]]"] related_apis: ["POST /api/auth/passkey/register/challenge", "POST /api/auth/passkey/register", "POST /api/auth/passkey/authenticate/challenge", "POST /api/auth/passkey/authenticate", "GET /api/auth/passkey/list", "DELETE /api/auth/passkey/:passkeyId"] --- # Passkey (WebAuthn) Flow > **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)) Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenges, cryptographically validates attestations and assertions via `@simplewebauthn/server`, stores credential metadata under `User.passkeys[]`, and finally issues the same JWT pair as the password flow. ## Actors - **User** with a WebAuthn-capable authenticator (Touch ID, Face ID, Windows Hello, Android biometric, YubiKey). - **Browser WebAuthn API** — `navigator.credentials.create()` / `.get()`. - **Frontend** — `frontend/src/auth/components/PasskeyManagement.tsx` (registration UI on the account settings page) and `frontend/src/auth/components/PasskeySignIn.tsx` (sign-in entry). - **Backend** — `backend/src/services/auth/passkeyService.ts` and the routes in `backend/src/services/auth/passkeyRoutes.ts`. - **MongoDB** — `User.passkeys[]` subdocument array (id, publicKey, counter, deviceType, deviceName, createdAt). ## Preconditions - The browser supports WebAuthn (`window.PublicKeyCredential`). The frontend checks this and throws `"WebAuthn در این مرورگر پشتیبانی نمی‌شود"` otherwise. - The relying party ID derives from `config.frontendUrl` — `backend/src/services/auth/passkeyService.ts:36` strips scheme/port to produce the WebAuthn `rpId`. - For **registration**, the user is already authenticated via password or Google (the `/passkey/register/*` routes are behind `authService.authenticateToken`). - For **sign-in**, no auth is required — the authenticator's credential ID identifies the user. - Env vars consumed by the frontend (commonly `NEXT_PUBLIC_PASSKEY_RP_ID`, `NEXT_PUBLIC_PASSKEY_RP_NAME`, `NEXT_PUBLIC_PASSKEY_ORIGIN` per the codebase conventions) drive WebAuthn options on the client. - **Important:** `next.config.ts` rewrites `/api/:path*` directly to the Express backend. There are **no** Next.js API route handler files for passkey paths — calls go straight to Express. Configure `PASSKEY_RP_ORIGIN` (and the corresponding `NEXT_PUBLIC_*` vars) to the frontend origin so the Express handler and the browser agree on the expected origin during challenge verification. ## Registration flow 1. From `/dashboard/account/security`, the user opens the Passkey management card and clicks **"Add new passkey"**. 2. Frontend `PasskeyManagement.tsx` calls `POST /api/auth/passkey/register/challenge` (with the bearer access token). 3. Backend `passkeyService.generateRegistrationChallenge(userId)` (`passkeyService.ts:58-70`): - `crypto.randomBytes(32).toString('base64url')` — a 256-bit challenge. - Stored in an in-memory `Map` (5-min TTL via interval cleanup). - Returns `{ challenge, rpId, userVerification: 'preferred', timeout: 60000 }`. 4. Frontend calls `navigator.credentials.create({ publicKey: { challenge, rp, user, pubKeyCredParams, ... } })`. The browser prompts the authenticator (Touch ID, etc.) and returns a `PublicKeyCredential` containing `id`, `rawId`, `response.clientDataJSON`, `response.attestationObject`. 5. Frontend POSTs `POST /api/auth/passkey/register` with `{ challenge, credential }`. 6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`): - Looks up the stored challenge → `{ userId }`. Deletes it (single-use). - Loads `User.findById(userId)`. - Calls `verifyRegistrationResponse()` from `@simplewebauthn/server`, which cryptographically validates the attestation object and extracts the COSE public key. - Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: Buffer.from(webAuthnCredential.publicKey).toString('base64url'), counter: webAuthnCredential.counter, deviceType, deviceName, createdAt: now }`. - Saves. 7. Frontend re-fetches `GET /api/auth/passkey/list` and renders the new entry. ## Authentication flow 1. From `/auth/jwt/sign-in`, the user clicks **"Sign in with passkey"**. 2. Frontend `PasskeySignIn.tsx` calls `POST /api/auth/passkey/authenticate/challenge` — note this is a **public** route (no bearer token). 3. Backend `passkeyService.generateAuthenticationChallengeForSignIn()` (`:88-105`) generates a 32-byte challenge and stores it with `userId: 'pending'`. 4. Frontend calls `navigator.credentials.get({ publicKey: { challenge, rpId, userVerification: 'preferred' } })`. The browser surfaces all matching passkeys for the rpId; the user picks one and approves biometrically. 5. The authenticator returns a `PublicKeyCredential` whose `response` includes `clientDataJSON`, `authenticatorData`, `signature`, `userHandle`. 6. Frontend POSTs `POST /api/auth/passkey/authenticate` with `{ challenge, assertion }`. 7. Backend `passkeyService.verifyAuthentication(challenge, assertion)` (`:149-272`): - Confirms the challenge exists (and deletes it). - `User.findOne({ 'passkeys.id': assertion.id })` — finds the user whose passkey matches the credential ID supplied by the authenticator. - Calls `verifyAuthenticationResponse()` from `@simplewebauthn/server`, passing the stored base64url-encoded COSE public key. This cryptographically verifies the signature over the authenticator data + client data hash. - Updates `passkey.counter` with the verified counter value returned by the library. - Issues JWT access + refresh tokens directly via `jwt.sign(...)` (`:230-248`). These are signed by the same `config.jwtSecret` as `authService`, so they are interchangeable with password-issued tokens. - Persists the refresh token: `user.refreshTokens.push(refreshToken); await user.save()` (`:281-282`). The standard `/api/auth/refresh-token` endpoint will accept passkey-issued tokens. 8. Response: `{ success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }`. 9. Frontend stores tokens in `localStorage` and redirects to the dashboard. ## Sequence diagram ```mermaid sequenceDiagram autonumber actor U as User participant FE as Frontend participant W as WebAuthn (Browser) participant BE as Backend participant DB as MongoDB rect rgb(245,247,250) Note over U,DB: Registration (user already authenticated) U->>FE: Click "Add passkey" FE->>BE: POST /api/auth/passkey/register/challenge (Bearer) BE->>BE: generateRegistrationChallenge(userId)\nstore in Map BE-->>FE: { challenge, rpId, ... } FE->>W: navigator.credentials.create({ publicKey }) W-->>FE: PublicKeyCredential (attestation) FE->>BE: POST /api/auth/passkey/register { challenge, credential } BE->>BE: verifyRegistrationResponse() — attestation verified\nCOSE public key extracted BE->>DB: user.passkeys.push({ id, publicKey (base64url COSE), counter, deviceType }) BE-->>FE: { success: true } end rect rgb(245,247,250) Note over U,DB: Authentication (no prior session) U->>FE: Click "Sign in with passkey" FE->>BE: POST /api/auth/passkey/authenticate/challenge (public) BE->>BE: generateAuthenticationChallengeForSignIn → store BE-->>FE: { challenge, rpId, ... } FE->>W: navigator.credentials.get({ publicKey }) W-->>FE: PublicKeyCredential (assertion) FE->>BE: POST /api/auth/passkey/authenticate { challenge, assertion } BE->>BE: consume challenge BE->>DB: User.findOne({ 'passkeys.id': assertion.id }) DB-->>BE: user with matching passkey BE->>BE: verifyAuthenticationResponse() — signature verified\nagainst stored COSE public key BE->>DB: passkey.counter updated\nuser.refreshTokens.push(refreshToken) BE->>BE: jwt.sign(access) / jwt.sign(refresh) BE-->>FE: { success, user, tokens } FE->>FE: localStorage.setItem(tokens) FE-->>U: Redirect /dashboard end ``` ## API calls | Method | Endpoint | Auth | Source | |---|---|---|---| | `POST` | `/api/auth/passkey/register/challenge` | Bearer | `passkeyRoutes.ts:50` | | `POST` | `/api/auth/passkey/register` | Bearer | `passkeyRoutes.ts:66` | | `POST` | `/api/auth/passkey/authenticate/challenge` | Public | `passkeyRoutes.ts:10` | | `POST` | `/api/auth/passkey/authenticate` | Public | `passkeyRoutes.ts:23` | | `GET` | `/api/auth/passkey/list` | Bearer | `passkeyRoutes.ts:87` | | `DELETE` | `/api/auth/passkey/:passkeyId` | Bearer | `passkeyRoutes.ts:103` | ## Database writes - **`users.passkeys`** — append on register (stores real base64url-encoded COSE public key), increment `counter` on each successful auth, splice on delete. - **`users.refreshTokens`** — the passkey authentication path pushes the new refresh token into `user.refreshTokens[]` (`passkeyService.ts:281-282`) and saves the document. Passkey-issued refresh tokens are valid for the standard `/api/auth/refresh-token` endpoint. ## Socket events emitted - None directly. The frontend joins the same Socket.IO rooms after login as in [[Authentication Flow]]. ## Side effects - **In-memory `storedChallenges` map**: per-instance, not Redis. On a horizontally scaled deployment, the challenge created on instance A can only be verified on instance A. Either pin to a single instance, use sticky sessions, or move to Redis (`paymentRedisService`-style). - **Cleanup interval**: every 5 minutes, expired challenges (>5 min old) are removed (`passkeyService.ts:42-55`). ## Error / edge cases - **Browser without WebAuthn** → frontend throws localized error before issuing the challenge request. - **User cancels biometric prompt** → `NotAllowedError` from the browser; frontend shows "Cancelled" toast. - **Challenge expired or unknown** → backend `Invalid or expired challenge` (`:117`). Frontend asks user to retry. - **Credential ID does not match any user** → `404 Passkey not found` (`passkeyRoutes.ts:40-44`). UX should suggest signing in by email instead. - **Multi-instance backend** (challenge stored on instance A, verified on instance B) → verification fails. Fix by moving `storedChallenges` to Redis. - **Replay / cloned authenticator** — `verifyAuthenticationResponse()` from `@simplewebauthn/server` checks that the new counter is strictly greater than the stored counter and will reject replays. > [!note] Production hardening checklist > 1. Move challenge storage to Redis to support multi-instance deploys. > 2. Add `excludeCredentials` during registration to prevent re-registering the same passkey. > 3. Ensure `PASSKEY_RP_ORIGIN` matches the actual frontend origin (no Next.js intermediary — rewrites go straight to Express). ## Linked flows - [[Authentication Flow]] — token semantics are identical post-issuance. - [[Registration Flow]] — passkey is an additional credential, not a replacement for initial account creation. ## Source files - Backend: `backend/src/services/auth/passkeyService.ts` - Backend: `backend/src/services/auth/passkeyRoutes.ts` - Frontend: `frontend/src/auth/components/PasskeyManagement.tsx` - Frontend: `frontend/src/auth/components/PasskeySignIn.tsx`