Passwords are still the default in most MEAN apps we are called into, and they are still the reason those apps get breached. In 2026 passkeys are no longer an experiment: Apple, Google and Microsoft all sync them across devices, every current browser ships the WebAuthn Level 3 APIs, and navigator.credentials conditional UI ("autofill passkeys") is broadly supported. This tutorial adds passkey sign-in to a MEAN stack app — MongoDB 8 with Mongoose 8, Express 5, Angular 22 with signals, on Node.js 24 LTS — alongside the password login you already have, so nobody gets locked out.
We use the @simplewebauthn/server and @simplewebauthn/browser libraries. They handle CBOR parsing, attestation formats and the signature checks you should never hand-roll.
How WebAuthn actually works (the two-minute version)
There are two ceremonies, and each is two HTTP round trips:
- Registration. The server issues a random challenge plus the relying party (RP) info. The authenticator (Touch ID, Windows Hello, a phone, a YubiKey) creates a key pair, keeps the private key, and returns a public key plus a credential ID. The server stores them.
- Authentication. The server issues a challenge and, optionally, a list of allowed credential IDs. The authenticator signs the challenge with the private key. The server verifies the signature against the stored public key and checks the counter.
Three values must be pinned server-side or the whole thing is theatre:
rpID— the registrable domain, e.g.app.example.comorexample.com. Never take it from the request.origin— the exact origin you expect, e.g.https://app.example.com.- The challenge — single use, short-lived, stored server-side against the session or user.
WebAuthn requires a secure context. localhost counts during development; everything else needs HTTPS.
Prerequisites
- Node.js 24 LTS, Angular CLI 22, MongoDB 8 (Atlas or
docker run -d -p 27017:27017 mongo:8.0). - An existing Express 5 API with session or JWT auth. The code below assumes the JWT helper (
issueToken,requireAuth) from our "Building a Modern MEAN App in 2026" tutorial.
cd api
npm install @simplewebauthn/server
cd ../web
npm install @simplewebauthn/browser
Step 1: the credential model
Store one document per credential — users will register a laptop, a phone and maybe a hardware key.
// api/src/models/credential.js
import { Schema, model } from 'mongoose';
const credentialSchema = new Schema({
user: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
credentialId: { type: String, required: true, unique: true }, // base64url
publicKey: { type: Buffer, required: true }, // COSE public key
counter: { type: Number, required: true, default: 0 },
transports: { type: [String], default: [] }, // 'internal', 'hybrid', 'usb'...
deviceType: { type: String }, // 'singleDevice' | 'multiDevice'
backedUp: { type: Boolean, default: false },
label: { type: String, default: 'Passkey' },
lastUsedAt: { type: Date },
}, { timestamps: true });
export const Credential = model('Credential', credentialSchema);
Add a stable, non-PII WebAuthn user handle to your User schema. Do not reuse the email — the handle is stored on the authenticator and cannot be rotated easily.
import { randomBytes } from 'node:crypto';
userSchema.add({
webauthnUserId: {
type: String,
unique: true,
sparse: true,
default: () => randomBytes(32).toString('base64url'),
},
});
Challenges need a short-lived home. A TTL collection keeps it simple and works across multiple API instances:
const challengeSchema = new Schema({
key: { type: String, required: true, unique: true }, // user id or anonymous session id
challenge: { type: String, required: true },
type: { type: String, enum: ['registration', 'authentication'], required: true },
createdAt: { type: Date, default: Date.now, expires: 300 }, // 5 minutes
});
export const Challenge = model('Challenge', challengeSchema);
Step 2: configuration
// api/src/webauthn/config.js
export const rpName = 'StackPilots Demo';
export const rpID = process.env.WEBAUTHN_RP_ID ?? 'localhost';
export const expectedOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
In production set WEBAUTHN_RP_ID=app.example.com and WEBAUTHN_ORIGIN=https://app.example.com. If you serve the Angular app and API from different hosts, the origin is the browser origin, not the API's.
Step 3: registration endpoints
// api/src/webauthn/registration.js
import { Router } from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { User } from '../models.js';
import { Credential, Challenge } from '../models/credential.js';
import { requireAuth } from '../auth.js';
import { rpName, rpID, expectedOrigin } from './config.js';
export const registrationRouter = Router();
registrationRouter.post('/webauthn/register/options', requireAuth, async (req, res) => {
const user = await User.findById(req.user.sub).orFail();
const existing = await Credential.find({ user: user._id }).lean();
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: Buffer.from(user.webauthnUserId, 'base64url'),
userName: user.email,
userDisplayName: user.email,
attestationType: 'none',
excludeCredentials: existing.map((c) => ({
id: c.credentialId,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: 'preferred', // discoverable credential => usernameless login
userVerification: 'preferred', // biometric or PIN when available
},
});
await Challenge.findOneAndUpdate(
{ key: String(user._id) },
{ challenge: options.challenge, type: 'registration', createdAt: new Date() },
{ upsert: true },
);
res.json(options);
});
registrationRouter.post('/webauthn/register/verify', requireAuth, async (req, res) => {
const user = await User.findById(req.user.sub).orFail();
const record = await Challenge.findOne({ key: String(user._id), type: 'registration' });
if (!record) return res.status(400).json({ error: 'challenge expired' });
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: record.challenge,
expectedOrigin,
expectedRPID: rpID,
requireUserVerification: false,
});
await record.deleteOne();
if (!verification.verified) return res.status(400).json({ error: 'verification failed' });
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
await Credential.create({
user: user._id,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
label: req.body.label?.slice(0, 60) || 'Passkey',
});
res.status(201).json({ verified: true });
});
excludeCredentials is what stops a user registering the same authenticator twice — the browser greys it out instead of creating a duplicate.
Note the Express 5 detail: these handlers are async and throw freely (orFail()), because Express 5 forwards rejected promises to the error middleware. No asyncHandler wrapper needed.
Step 4: authentication endpoints
// api/src/webauthn/authentication.js
import { Router } from 'express';
import { randomUUID } from 'node:crypto';
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { User } from '../models.js';
import { Credential, Challenge } from '../models/credential.js';
import { issueToken } from '../auth.js';
import { rpID, expectedOrigin } from './config.js';
export const authenticationRouter = Router();
authenticationRouter.post('/webauthn/login/options', async (req, res) => {
// Usernameless: no allowCredentials at all, let the browser offer discoverable passkeys.
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
});
const flowId = randomUUID();
await Challenge.create({ key: flowId, challenge: options.challenge, type: 'authentication' });
res.json({ flowId, options });
});
authenticationRouter.post('/webauthn/login/verify', async (req, res) => {
const { flowId, response } = req.body;
const record = await Challenge.findOne({ key: flowId, type: 'authentication' });
if (!record) return res.status(400).json({ error: 'challenge expired' });
const stored = await Credential.findOne({ credentialId: response.id });
if (!stored) return res.status(401).json({ error: 'unknown credential' });
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: record.challenge,
expectedOrigin,
expectedRPID: rpID,
credential: {
id: stored.credentialId,
publicKey: new Uint8Array(stored.publicKey),
counter: stored.counter,
transports: stored.transports,
},
requireUserVerification: false,
});
await record.deleteOne();
if (!verification.verified) return res.status(401).json({ error: 'verification failed' });
const { newCounter } = verification.authenticationInfo;
if (stored.counter > 0 && newCounter <= stored.counter) {
// Possible cloned authenticator. Refuse and alert.
return res.status(401).json({ error: 'counter regression' });
}
stored.counter = newCounter;
stored.lastUsedAt = new Date();
await stored.save();
const user = await User.findById(stored.user).orFail();
res.json({ token: issueToken(user), email: user.email });
});
Two things worth pausing on. First, the counter check: synced passkeys usually report 0 forever, so only enforce regression when the stored counter is already above zero. Second, returning unknown credential versus verification failed leaks nothing useful here because the credential ID is already a random public value — but keep your password login responses uniform.
Step 5: the Angular 22 side
A single injectable service wraps the browser helpers and exposes signals.
// web/src/app/auth/passkey.service.ts
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
startRegistration,
startAuthentication,
browserSupportsWebAuthn,
browserSupportsWebAuthnAutofill,
} from '@simplewebauthn/browser';
@Injectable({ providedIn: 'root' })
export class PasskeyService {
private http = inject(HttpClient);
readonly supported = signal(browserSupportsWebAuthn());
readonly busy = signal(false);
readonly error = signal<string | null>(null);
async register(label: string): Promise<boolean> {
this.busy.set(true);
this.error.set(null);
try {
const options = await firstValueFrom(
this.http.post<PublicKeyCredentialCreationOptionsJSON>('/api/webauthn/register/options', {}),
);
const attResp = await startRegistration({ optionsJSON: options });
await firstValueFrom(
this.http.post('/api/webauthn/register/verify', { ...attResp, label }),
);
return true;
} catch (err: unknown) {
this.error.set(this.describe(err));
return false;
} finally {
this.busy.set(false);
}
}
async login(useAutofill = false): Promise<{ token: string } | null> {
this.busy.set(true);
this.error.set(null);
try {
const { flowId, options } = await firstValueFrom(
this.http.post<{ flowId: string; options: PublicKeyCredentialRequestOptionsJSON }>(
'/api/webauthn/login/options', {},
),
);
const response = await startAuthentication({ optionsJSON: options, useBrowserAutofill: useAutofill });
return await firstValueFrom(
this.http.post<{ token: string }>('/api/webauthn/login/verify', { flowId, response }),
);
} catch (err: unknown) {
this.error.set(this.describe(err));
return null;
} finally {
this.busy.set(false);
}
}
autofillAvailable() {
return browserSupportsWebAuthnAutofill();
}
private describe(err: unknown): string {
const name = (err as { name?: string })?.name;
if (name === 'NotAllowedError') return 'Passkey prompt was dismissed or timed out.';
if (name === 'InvalidStateError') return 'This device already has a passkey for your account.';
return 'Passkey sign-in failed. Try your password instead.';
}
}
Conditional UI is the feature users actually notice: the passkey appears in the email field's autofill dropdown. It needs autocomplete="username webauthn" on the input and an authentication ceremony started on page load.
// web/src/app/auth/login.component.ts
import { Component, inject, OnInit, signal } from '@angular/core';
import { Router } from '@angular/router';
import { PasskeyService } from './passkey.service';
@Component({
selector: 'app-login',
standalone: true,
template: `
<form (submit)="passwordLogin($event)">
<input name="email" type="email" autocomplete="username webauthn" [value]="email()"
(input)="email.set($any($event.target).value)" />
<input name="password" type="password" autocomplete="current-password" />
<button type="submit">Sign in</button>
</form>
@if (passkeys.supported()) {
<button type="button" [disabled]="passkeys.busy()" (click)="passkeyLogin()">
Sign in with a passkey
</button>
}
@if (passkeys.error(); as message) { <p role="alert">{{ message }}</p> }
`,
})
export class LoginComponent implements OnInit {
protected passkeys = inject(PasskeyService);
private router = inject(Router);
protected email = signal('');
async ngOnInit() {
if (await this.passkeys.autofillAvailable()) {
const result = await this.passkeys.login(true); // resolves when the user picks a passkey
if (result) this.finish(result.token);
}
}
async passkeyLogin() {
const result = await this.passkeys.login(false);
if (result) this.finish(result.token);
}
passwordLogin(event: Event) { /* existing flow */ }
private finish(token: string) {
sessionStorage.setItem('token', token);
this.router.navigateByUrl('/app');
}
}
Because the autofill ceremony is started in ngOnInit and only settles when the user chooses a credential, abort it if the component is destroyed — keep an AbortController in the service and call startAuthentication again after aborting, otherwise a second ceremony throws InvalidStateError.
Step 6: credential management
Users need a settings screen that lists passkeys, renames them and removes them. Two rules keep support tickets down:
- Never let a user delete their last authentication factor without confirming a password or emailing a recovery link.
- Show
lastUsedAt,deviceTypeandbackedUpso "which key is this?" is answerable. AsingleDevice, non-backed-up credential is a hardware key or a platform key that will vanish with the laptop.
registrationRouter.get('/webauthn/credentials', requireAuth, async (req, res) => {
const list = await Credential.find({ user: req.user.sub })
.select('label transports deviceType backedUp lastUsedAt createdAt')
.sort({ createdAt: -1 })
.lean();
res.json(list);
});
registrationRouter.delete('/webauthn/credentials/:id', requireAuth, async (req, res) => {
const result = await Credential.deleteOne({ _id: req.params.id, user: req.user.sub });
if (!result.deletedCount) return res.status(404).json({ error: 'not found' });
res.status(204).end();
});
Step 7: testing without a real fingerprint
You do not need a thumb in CI. Chrome DevTools Protocol exposes a virtual authenticator, and Playwright can drive it:
import { test, expect } from '@playwright/test';
test('registers and uses a passkey', async ({ page, context }) => {
const client = await context.newCDPSession(page);
await client.send('WebAuthn.enable');
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true,
hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
});
await page.goto('/settings/security');
await page.getByRole('button', { name: 'Add a passkey' }).click();
await expect(page.getByText('Passkey added')).toBeVisible();
await page.getByRole('button', { name: 'Sign out' }).click();
await page.getByRole('button', { name: 'Sign in with a passkey' }).click();
await expect(page).toHaveURL(/\/app/);
await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
});
For the API layer, unit-test verifyAuthenticationResponse against fixtures captured from a virtual authenticator, plus these cases: expired challenge, replayed challenge, wrong expectedOrigin, wrong rpID, and counter regression. Each of those should be a 400 or 401, never a 500.
Deployment checklist
- HTTPS everywhere;
rpIDandorigincome from environment variables, never fromreq.headers.host. - Challenges stored server-side (the TTL collection above), single use, deleted after verification.
- Rate-limit
/webauthn/login/optionsand/webauthn/login/verifyper IP — unauthenticated endpoints that hit the database. - Keep password login and a recovery path alive during rollout; instrument adoption with
lastUsedAt. - If you serve the Angular app under a subdomain today and plan to move, set
rpIDto the parent registrable domain now. ChangingrpIDlater invalidates every stored credential. - Log every registration and deletion to your audit trail, with IP and user agent.
Where this fits
Passkeys remove the phishable secret; they do not fix session handling, NoSQL injection or over-broad JWT lifetimes. Pair this with short-lived access tokens plus refresh rotation, and with the input-validation defenses in our post on NoSQL injection in MEAN apps.
If you want a passkey rollout done properly — threat model, migration plan, credential recovery flows, and the Angular UX that keeps support volume flat — our MEAN Stack consultants do exactly this kind of work. Get in touch with the shape of your app and we will scope it.