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.
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
From /dashboard/account/security, the user opens the Passkey management card and clicks "Add new passkey".
Frontend PasskeyManagement.tsx calls POST /api/auth/passkey/register/challenge (with the bearer access token).
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.
Frontend re-fetches GET /api/auth/passkey/list and renders the new entry.
Authentication flow
From /auth/jwt/sign-in, the user clicks "Sign in with passkey".
Frontend PasskeySignIn.tsx calls POST /api/auth/passkey/authenticate/challenge — note this is a public route (no bearer token).
Backend passkeyService.generateAuthenticationChallengeForSignIn() (:88-105) generates a 32-byte challenge and stores it with userId: 'pending'.
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.
The authenticator returns a PublicKeyCredential whose response includes clientDataJSON, authenticatorData, signature, userHandle.
Frontend POSTs POST /api/auth/passkey/authenticate with { challenge, assertion }.
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.
Frontend stores tokens in localStorage and redirects to the dashboard.
Sequence diagram
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
Move challenge storage to Redis to support multi-instance deploys.
Add excludeCredentials during registration to prevent re-registering the same passkey.
Ensure PASSKEY_RP_ORIGIN matches the actual frontend origin (no Next.js intermediary — rewrites go straight to Express).