Passkeys have gone mainstream, but most developer guides still stop at basic login demos. In 2026, production teams need phishing-resistant authentication that works across browsers, mobile devices, and edge runtimes without turning session management into a security mess. In this guide, you will build a practical passkey-first auth flow for a modern web app using WebAuthn, challenge signing, and secure session cookies, with code you can adapt to Next.js 15 or any Node backend.
Why passkey-first auth matters in 2026
Passwords remain the weakest link in most web stacks. Even with MFA, phishing kits now proxy OTP flows in real time. Passkeys (FIDO2/WebAuthn credentials) solve this by binding login to:
- A real origin (your exact domain)
- A private key stored in secure hardware or OS keychain
- User verification (biometric or device PIN)
The result is strong phishing resistance, better UX, and less account-recovery overhead for support teams.
Architecture we will implement
Our production-ready pattern:
- User enters email (or chooses discoverable login).
- Server creates a short-lived challenge and stores it with nonce metadata.
- Browser calls
navigator.credentials.create()orget(). - Browser returns attestation/assertion to server.
- Server verifies origin, RP ID, challenge, signature counter, and UV.
- Server issues an HttpOnly secure session cookie.
Data model
Keep your credential model explicit so rotation and auditing stay simple.
// pseudo-schema (Postgres)
users (
id uuid pk,
email text unique not null,
created_at timestamptz not null
)
webauthn_credentials (
id uuid pk,
user_id uuid references users(id),
credential_id bytea unique not null,
public_key bytea not null,
counter bigint not null default 0,
transports text[],
aaguid uuid,
backed_up boolean,
created_at timestamptz not null,
last_used_at timestamptz
)
auth_challenges (
id uuid pk,
user_id uuid null,
challenge text not null,
purpose text not null, -- register | login
expires_at timestamptz not null,
consumed_at timestamptz null
)
Server setup (Node.js + @simplewebauthn/server)
You can use any framework. Below uses Fastify-style handlers for clarity.
1) Generate registration options
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
const rpName = '7Tech Demo';
const rpID = 'example.com'; // production domain
const origin = 'https://example.com';
export async function beginRegistration(user) {
const existingCreds = await db.credentials.findByUser(user.id);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.email,
userID: user.id,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
authenticatorAttachment: 'platform',
},
excludeCredentials: existingCreds.map(c => ({
id: c.credentialID,
transports: c.transports || ['internal'],
})),
});
await db.challenges.create({
userId: user.id,
challenge: options.challenge,
purpose: 'register',
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
return options;
}
2) Verify registration response
export async function finishRegistration(user, body) {
const challenge = await db.challenges.findActive(user.id, 'register');
if (!challenge) throw new Error('No active challenge');
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: challenge.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error('Registration verification failed');
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
await db.credentials.insert({
userId: user.id,
credentialID: Buffer.from(credential.id),
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: body.response.transports || ['internal'],
backedUp: credentialBackedUp,
});
await db.challenges.consume(challenge.id);
return { ok: true };
}
3) Begin and verify login
export async function beginLogin(user) {
const creds = await db.credentials.findByUser(user.id);
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: creds.map(c => ({
id: c.credentialID,
transports: c.transports || ['internal'],
})),
userVerification: 'preferred',
});
await db.challenges.create({
userId: user.id,
challenge: options.challenge,
purpose: 'login',
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
return options;
}
export async function finishLogin(user, body, reply) {
const challenge = await db.challenges.findActive(user.id, 'login');
const credential = await db.credentials.findByCredentialID(body.id);
if (!challenge || !credential) throw new Error('Invalid login state');
const verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: challenge.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialID,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
requireUserVerification: true,
});
if (!verification.verified) throw new Error('Authentication failed');
await db.credentials.updateCounter(credential.id, verification.authenticationInfo.newCounter);
await db.challenges.consume(challenge.id);
const sessionToken = await sessions.issue(user.id);
reply.setCookie('session', sessionToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
return { ok: true };
}
Client-side WebAuthn calls
import {
startRegistration,
startAuthentication,
} from '@simplewebauthn/browser';
export async function registerPasskey(email) {
const begin = await fetch('/api/auth/passkey/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}).then(r => r.json());
const attResp = await startRegistration(begin);
await fetch('/api/auth/passkey/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attResp),
});
}
export async function loginWithPasskey(email) {
const begin = await fetch('/api/auth/passkey/login/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}).then(r => r.json());
const authResp = await startAuthentication(begin);
await fetch('/api/auth/passkey/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResp),
});
}
Production hardening checklist
- Challenge TTL: 3-5 minutes, single-use only.
- Replay prevention: mark challenges consumed immediately.
- Origin correctness: explicitly verify production and staging origins.
- Counter checks: detect cloned authenticators by counter rollback.
- Recovery: provide at least two passkeys per account and secure recovery flow.
- Rate limits: per-IP + per-account on begin/finish endpoints.
- Audit logs: record registration, login success/failure, and recovery events.
Edge runtime notes (Next.js 15 / Workers)
WebAuthn verification often needs binary utilities. If you run at the edge, test your crypto and CBOR dependencies carefully. Many teams keep verification in a regional Node service and use edge middleware only for session checks and request routing. This balances latency with library compatibility.
Final thoughts
Passkeys are not just a UX upgrade, they are one of the most effective phishing defenses available to web developers today. If you implement challenge lifecycle, strict origin validation, and secure session handling correctly, you get a login flow that is faster for users and significantly harder for attackers to compromise. Start with passkey-first auth for new accounts, then progressively migrate existing users, and you will future-proof your web security posture for 2026 and beyond.

Leave a Reply