Passwordless sign-in using WebAuthn / Passkeys. The backend issues challenges, validates signed assertions, 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.
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).
Appends to user.passkeys[]: { id: credential.id, publicKey: 'simulated-public-key', counter: 0, deviceType: 'platform', deviceName: 'Default Device', createdAt: now }.
Saves.
Frontend re-fetches GET /api/auth/passkey/list and renders the new entry.
[!warning] Attestation validation is stubbed
passkeyService.verifyRegistration currently does not parse the attestation object or extract the real COSE public key — see the comment block at passkeyService.ts:122-128 ("In a real implementation, you would..."). The publicKey field is the literal string 'simulated-public-key'. This means a malicious client could register an attacker-controlled credential ID under any user; harden this before production. Use @simplewebauthn/server to parse attestation and store the verified public key.
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.
passkey.counter += 1 (the schema stores a counter; a real implementation must reject replays where the new counter is not strictly greater than the stored one).
Issues JWT access + refresh tokens directly via jwt.sign(...) (:230-248). Note: these are signed by the same config.jwtSecret as in authService, so they are interchangeable with password-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
FE->>BE: POST /api/auth/passkey/register { challenge, credential }
BE->>BE: verifyRegistration → consume challenge
BE->>DB: user.passkeys.push({ id, 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->>DB: passkey.counter += 1
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, increment counter on each successful auth, splice on delete.
A new refresh token is not appended to user.refreshTokens in the current passkey path (the JWT is signed directly without round-tripping through authService.generateRefreshToken). This means the password-flow refresh-token allow-list does not apply to passkey logins. See edge cases.
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 — current implementation does not strictly enforce monotonic counter; revisit before production.
Refresh-token rotation gap — passkey-issued refresh tokens are not added to user.refreshTokens[]. The standard /api/auth/refresh-token will reject them on the next refresh. Until fixed, treat passkey access tokens as short-lived (the user must passkey-sign-in again after expiry) or unify token issuance through authService.generateRefreshToken and persist them.
[!warning] Production hardening checklist
Replace stub attestation parsing with @simplewebauthn/server.
Persist the COSE public key, not a stub string.
Enforce strictly increasing counter (signal of cloned authenticator if not).
Move challenge storage to Redis to support multi-instance deploys.
Add excludeCredentials during registration to prevent re-registering the same passkey.
Push the passkey-issued refresh token into user.refreshTokens[].