WebAuthn in a TypeScript API: how passkeys actually work end to end
WebAuthn in a TypeScript API: how passkeys actually work end to end
Walk the passkey implementation in
modules/user_security/— registration, authentication, resident key flow, and why Redis handles challenge storage instead of the database.
WebAuthn documentation describes the protocol in careful, standards-compliant language. What it does not cover is what your server code actually looks like once you connect @simplewebauthn/server to a TypeScript API backed by Postgres and Redis. The gap between "the spec says" and "the service file does" is where implementation decisions live: how do you store challenges without a cleanup job? How do you support both email-bound login and passwordless (resident key) flow from the same endpoint? How many passkeys should one user be allowed?
These questions are answered in modules/user_security/user_security.passkey.service.ts. This is a walkthrough of that file.
The output the reader sees from the outside
A user on the registration flow touches a fingerprint reader or security key. Their browser generates a credential. The server verifies the credential, stores a credentialId and publicKey against the user record, and sets passkeyEnabled: true. On subsequent logins, the user skips the password field entirely.
From a product perspective: WebAuthn is a passkey feature. Users call it "log in with fingerprint" or "Face ID login" or "security key". The server-side plumbing — challenge generation, verification, counter management — is invisible to the user but non-trivial to implement correctly. The implementation in this boilerplate uses @simplewebauthn/server to handle the cryptographic operations and owns the application logic around challenge storage and passkey persistence itself.
What the repository makes visible about this feature:
modules/user_security/
├── entities/
├── user_security.enums.ts
├── user_security.passkey.constants.ts
├── user_security.passkey.messages.ts
├── user_security.passkey.service.ts
├── user_security.service.ts
├── user_security.service.test.ts
├── user_security.setting.keys.ts
└── user_security.types.ts
One module, one address. The constants, the messages, the service, the tests — all inside modules/user_security/.
The decision tree behind the output
Five decisions shaped the implementation:
- Where to store challenges: Redis with TTL vs. a database table with a cleanup cron vs. in-memory with the attendant single-instance constraint. Redis with TTL won.
- Where to store passkeys: A separate
passkeystable vs. a JSONB column on theuserstable vs. a JSONB column on auser_securityentity. The JSONB approach on the security entity won. - How many passkeys per user: Unbounded vs. a configured constant. A constant won —
PASSKEY_MAX_PER_USER = 10. - Resident keys (usernameless login): Support both email-bound (user types email, authenticator confirms identity) and resident key (authenticator supplies identity from a discoverable credential) flows, or only one.
- Counter updates: Update on every authentication vs. batch update. Immediate update on each successful auth won — necessary for the replay protection the counter provides.
Walk each node with a real artifact in the repo
Challenge storage is defined in constants:
// modules/user_security/user_security.passkey.constants.ts
export const PASSKEY_REG_CHALLENGE_KEY = (userId: string) => `passkey:reg:challenge:${userId}`;
export const PASSKEY_AUTH_CHALLENGE_KEY = (userId: string) => `passkey:auth:challenge:${userId}`;
export const PASSKEY_EMAIL_CHALLENGE_KEY = (email: string) => `passkey:email:challenge:${email}`;
export const PASSKEY_CHALLENGE_TTL_SECONDS = 300; // 5 minutes
export const PASSKEY_MAX_PER_USER = 10;
Five minutes. If a user opens the passkey registration dialog and does not complete it within five minutes, the challenge evicts itself from Redis. No cleanup job, no WHERE expires_at < NOW() query, no orphaned rows accumulating over time. The key naming convention makes the Redis keyspace auditable: redis-cli KEYS passkey:* shows every active challenge across all flows.
Registration generates a challenge, stores it in Redis, and waits for the client:
// modules/user_security/user_security.passkey.service.ts (excerpt)
static async generateRegistrationOptions(user: SafeUser): Promise<Record<string, unknown>> {
const userSecurity = await UserSecurityService.getByUserId(user.userId);
if (userSecurity.passkeys.length >= PASSKEY_MAX_PER_USER) {
throw new Error(PasskeyMessages.PASSKEY_LIMIT_REACHED);
}
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userName: user.email,
userDisplayName: (user as any).name ?? user.email,
attestationType: 'none',
excludeCredentials,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
await redis.set(
PASSKEY_REG_CHALLENGE_KEY(user.userId),
options.challenge,
'EX',
PASSKEY_CHALLENGE_TTL_SECONDS
);
return options as unknown as Record<string, unknown>;
}
attestationType: 'none' means the server does not request a certificate chain proving the authenticator's hardware origin — correct for consumer passkeys where you want broad compatibility over high-assurance verification. residentKey: 'preferred' asks the authenticator to store the credential as a discoverable credential when possible, enabling the usernameless flow.
Verification reads the challenge from Redis, verifies the response, writes the passkey to the user record, then deletes the challenge:
static async verifyRegistration({ user, response, label }) {
const challenge = await redis.get(PASSKEY_REG_CHALLENGE_KEY(user.userId));
if (!challenge) throw new Error(PasskeyMessages.PASSKEY_CHALLENGE_EXPIRED);
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: false,
});
const newPasskey: StoredPasskey = {
credentialId,
publicKey,
counter: credential.counter,
aaguid,
label: label ?? `Passkey ${new Date().toLocaleDateString()}`,
createdAt: new Date().toISOString(),
lastUsedAt: null,
transports: (credential.transports ?? []) as string[],
};
await UserSecurityService.updateUserSecurity(user.userId, {
passkeyEnabled: true,
passkeys: [...userSecurity.passkeys, newPasskey],
});
await redis.del(PASSKEY_REG_CHALLENGE_KEY(user.userId));
return { credentialId };
}
The challenge is read once and deleted after use. A challenge can never be replayed because it no longer exists after a successful verification. The passkey is stored as a StoredPasskey object in the JSONB array — credentialId, publicKey (base64url-encoded), counter, aaguid, label, timestamps, and transport hints.
Resident key (usernameless) authentication is the more complex flow. The server does not know who the user is until the authenticator responds:
static async generateAuthenticationOptions(email?: string) {
if (email) {
// email-bound flow: pre-populate allowCredentials
cacheKey = PASSKEY_AUTH_CHALLENGE_KEY(user.userId);
} else {
// resident key flow: empty allowCredentials, device chooses
cacheKey = PASSKEY_EMAIL_CHALLENGE_KEY('_resident_');
}
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
allowCredentials,
});
await redis.set(cacheKey, options.challenge, 'EX', PASSKEY_CHALLENGE_TTL_SECONDS);
return options;
}
When email is undefined, allowCredentials is empty. The browser presents the user's available passkeys for this origin and lets them choose. The challenge is stored under a fixed _resident_ key because there is no user ID at this point in the flow.
Credential lookup during authentication uses a raw SQL query against the JSONB column:
const rows = await ds.query<{ userId: string }[]>(
`
SELECT "userId"
FROM "users"
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements("passkeys") AS pk
WHERE pk->>'credentialId' = $1
)
LIMIT 1
`,
[response.id]
);
The JSONB approach means a passkey is not a row you can look up with a primary key index — it is an element inside an array that requires a jsonb_array_elements expansion. For most applications with fewer than a hundred passkeys per user this is acceptable. For applications where passkey lookup is on the hot path, a separate passkeys table with an indexed credential_id column would be faster. The current implementation trades query simplicity for index performance.
The thing that almost went differently
Challenge storage in the database was the natural first instinct. A passkey_challenges table with userId, challenge, type, and expiresAt columns is straightforward to implement and keeps everything in one store. The problem is cleanup: expired challenges accumulate unless a cron job or scheduled query removes them. That cron job needs to run, needs to be monitored, and can fall behind under load.
Redis TTL makes the cleanup problem disappear. A challenge is a key with a five-minute expiry. Redis evicts it automatically. There is nothing to monitor except Redis memory pressure, which you are already monitoring because BullMQ uses the same instance.
The second consideration: challenge storage does not need durability. If Redis restarts and challenges evaporate, users re-initiate the flow. That is a slightly worse user experience than a database restart, but challenges are short-lived enough that the practical impact is minimal.
What you would change if starting over
The passkeys-as-JSONB approach has a specific limitation that becomes relevant at scale: you cannot add an index to a value inside a JSONB array. The raw SQL query with jsonb_array_elements works, but it scans the array linearly. For users with many passkeys (the constant is set to a maximum of ten) this is not a performance concern. For a system where passkeys are used at high frequency as the primary authentication method, a dedicated passkeys table would give you an indexed credential_id lookup.
The other thing worth revisiting: the userSecurity.passkeys array is fetched in full every time a lookup happens. For the ten-passkey case this is fine. For a hypothetical multi-device enterprise user with the maximum ten passkeys, it still means deserialising ten objects on every login to find one matching credentialId. Small, but not zero.
Trade-off
The JSONB passkey storage is simple to implement, atomic to update, and eliminates the join between user security state and individual passkeys. The trade-off is the inability to index individual passkeys for direct lookup and the JSON deserialisation overhead on every authentication attempt. The Redis challenge storage is operationally clean and eliminates a cleanup concern, at the cost of adding Redis as a hard dependency for auth flows. Both trade-offs are appropriate for the boilerplate's target scale; both would need revisiting for a system with millions of daily active WebAuthn authentications.
Business impact
WebAuthn is a security improvement with a measurable UX benefit. Users do not type passwords — they touch a sensor or confirm Face ID. Phishing is structurally mitigated because passkeys are bound to the origin (RP_ID) and cannot be transferred to attacker-controlled domains. For enterprise clients with compliance requirements, hardware-key support (a security key counts as a passkey) can satisfy MFA requirements without a separate TOTP setup.
The implementation visible here handles the resident key flow — users can log in without typing an email address on a device where they have registered a passkey. That is the experience users associate with "native apps" rather than web applications, and it removes a meaningful friction point from the authentication flow.
What to do next
If you are evaluating whether to add WebAuthn to an existing application: the hard parts are challenge storage (use Redis with TTL, not the database), passkey persistence (JSONB is acceptable for most applications; a separate table if you need index performance), and the resident key UX (decide whether you support usernameless login before building the frontend — it requires a different button and a different backend path).
The @simplewebauthn/server library handles the cryptographic operations. What you are building is the glue: challenge lifecycle, passkey storage, counter updates, and the resident key flow. This service file is an extractable reference for all four.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox