- Published on
Passkeys and WebAuthn — Replacing Passwords With Phishing-Resistant Authentication
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
WebAuthn represents a fundamental shift in authentication security. Unlike passwords vulnerable to phishing and credential stuffing, WebAuthn cryptographically binds authentication to the specific origin and device. Passkeys—passwordless credentials synced across a user's devices—deliver the security of hardware keys with the convenience of password managers.
In this guide, we'll implement complete WebAuthn flows: registration ceremonies, authentication ceremonies, credential storage, resident keys for usernameless login, and best-practice attestation verification.
- WebAuthn Registration Flow
- Attestation Verification
- Credential Storage in Database
- Authentication Ceremony
- Resident Credentials and Usernameless Login
- Device Binding and Passkey Sync
- Node.js FIDO2 Libraries
- Fallback to Password
- Conclusion
WebAuthn Registration Flow
Registration creates a new credential bound to your origin. The browser generates a public-private key pair; only the public key is stored server-side.
// lib/webauthn.ts
import { randomBytes } from 'crypto';
import base64url from 'base64url';
export interface RegistrationOptions {
challenge: string;
rp: {
name: string;
id: string;
};
user: {
id: string;
name: string;
displayName: string;
};
pubKeyCredParams: Array<{ type: string; alg: number }>;
authenticatorSelection: {
authenticatorAttachment?: string;
residentKey: 'preferred' | 'required' | 'discouraged';
userVerification: 'preferred' | 'required' | 'discouraged';
};
timeout: number;
attestation: 'none' | 'direct' | 'indirect' | 'enterprise';
}
export function generateRegistrationOptions(
userId: string,
username: string,
rpName: string,
rpId: string
): RegistrationOptions {
const challenge = base64url(randomBytes(32));
return {
challenge,
rp: { name: rpName, id: rpId },
user: {
id: base64url(Buffer.from(userId)),
name: username,
displayName: username,
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 }, // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform', // or 'cross-platform' for security keys
residentKey: 'preferred',
userVerification: 'preferred',
},
timeout: 60000,
attestation: 'direct', // 'none' for privacy, 'direct' for attestation verification
};
}
Client-side registration:
// components/RegisterWebAuthn.tsx
async function handleRegistration() {
const options = await fetch('/api/webauthn/register/options').then(r => r.json());
const credential = await navigator.credentials.create({
publicKey: {
...options,
challenge: Uint8Array.from(atob(options.challenge), c => c.charCodeAt(0)),
user: {
...options.user,
id: Uint8Array.from(atob(options.user.id), c => c.charCodeAt(0)),
},
},
}) as PublicKeyCredential;
if (!credential) throw new Error('Registration failed');
// Send attestationObject and clientDataJSON to server
const attestationObject = new Uint8Array(credential.response.attestationObject);
const clientDataJSON = new Uint8Array(credential.response.clientDataJSON);
await fetch('/api/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialId: base64url(credential.id),
attestationObject: base64url(attestationObject),
clientDataJSON: base64url(clientDataJSON),
}),
});
}
Attestation Verification
Attestation proves the credential came from a genuine authenticator. This is optional but recommended for high-security applications.
// lib/attestation.ts
import * as cbor from 'cbor';
import { createHash, createVerify } from 'crypto';
export async function verifyAttestation(
attestationObject: Buffer,
clientDataJSON: Buffer,
expectedOrigin: string,
expectedRpId: string
): Promise<{ credentialPublicKey: Buffer; credentialId: Buffer }> {
// Parse attestationObject (CBOR encoded)
const decodedAttestation = cbor.decode(attestationObject);
const { fmt, attStmt, authData } = decodedAttestation;
// Verify clientDataJSON
const clientData = JSON.parse(clientDataJSON.toString());
if (clientData.type !== 'webauthn.create') {
throw new Error('Invalid client data type');
}
if (clientData.origin !== expectedOrigin) {
throw new Error('Origin mismatch');
}
// Parse authData
const rpIdHash = authData.slice(0, 32);
const flags = authData[32];
const signCount = authData.readUInt32BE(33);
let offset = 37;
// Verify RP ID hash
const expectedRpIdHash = createHash('sha256').update(expectedRpId).digest();
if (!rpIdHash.equals(expectedRpIdHash)) {
throw new Error('RP ID hash mismatch');
}
// Extract credential ID and public key
const credentialIdLength = authData.readUInt16BE(offset);
offset += 2;
const credentialId = authData.slice(offset, offset + credentialIdLength);
offset += credentialIdLength;
const credentialPublicKey = authData.slice(offset);
// Verify attestation statement based on format
if (fmt === 'self') {
// Self attestation: verify signature in attStmt
const signedData = Buffer.concat([
attestationObject.slice(0, attestationObject.length - cbor.encode(attStmt).length),
clientDataJSON,
]);
const sig = attStmt.sig as Buffer;
const x5c = attStmt.x5c as Buffer[];
if (x5c && x5c.length > 0) {
const cert = x5c[0];
const pubKey = `-----BEGIN CERTIFICATE-----\n${cert.toString('base64')}\n-----END CERTIFICATE-----`;
const verifier = createVerify('sha256');
if (!verifier.update(signedData).verify(pubKey, sig)) {
throw new Error('Attestation signature invalid');
}
}
}
return { credentialPublicKey, credentialId };
}
Credential Storage in Database
Store credentials securely with proper indexing for authentication lookups.
// schema.ts (Prisma example)
model WebAuthnCredential {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
credentialId String @unique // base64url encoded
publicKey String // base64url encoded CBOR
credentialName String? // "My MacBook Pro"
counter Int @default(0) // sign count for cloned credential detection
transports String[] // "usb", "nfc", "ble", "internal"
aaguid String? // authenticator GUID
createdAt DateTime @default(now())
lastUsedAt DateTime?
@@index([userId])
@@index([credentialId])
}
Authentication Ceremony
During authentication, the server challenges the client; the authenticator signs with its private key.
// lib/authentication.ts
export interface AuthenticationOptions {
challenge: string;
timeout: number;
userVerification: 'preferred' | 'required' | 'discouraged';
rpId: string;
allowCredentials?: Array<{
id: string;
type: 'public-key';
transports?: string[];
}>;
}
export function generateAuthenticationOptions(
rpId: string,
userHandle?: string // resident key lookup
): AuthenticationOptions {
const challenge = base64url(randomBytes(32));
return {
challenge,
timeout: 60000,
userVerification: 'preferred',
rpId,
// allowCredentials omitted for discoverable credentials (resident keys)
};
}
export async function verifyAuthentication(
credential: any,
storedPublicKey: Buffer,
storedCounter: number,
clientDataJSON: Buffer,
expectedOrigin: string,
expectedChallenge: string
): Promise<number> {
const clientData = JSON.parse(clientDataJSON.toString());
if (clientData.type !== 'webauthn.get') {
throw new Error('Invalid client data type');
}
if (clientData.challenge !== expectedChallenge) {
throw new Error('Challenge mismatch');
}
if (clientData.origin !== expectedOrigin) {
throw new Error('Origin mismatch');
}
// Reconstruct authenticator data
const authenticatorData = new Uint8Array(credential.response.authenticatorData);
const newCounter = new DataView(authenticatorData.buffer).getUint32(33);
// Detect cloned credentials (monotonic counter check)
if (newCounter <= storedCounter) {
throw new Error('Credential appears cloned or replayed');
}
// Verify assertion signature
const clientDataHash = createHash('sha256').update(clientDataJSON).digest();
const signedData = Buffer.concat([authenticatorData, clientDataHash]);
const verifier = createVerify('sha256');
const sig = new Uint8Array(credential.response.signature);
if (!verifier.update(signedData).verify(storedPublicKey, sig)) {
throw new Error('Assertion signature invalid');
}
return newCounter;
}
Resident Credentials and Usernameless Login
Resident credentials store the username on the authenticator, enabling passwordless login without entering a username first.
// pages/api/webauthn/authenticate.ts
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { assertion } = req.body;
const authenticatorData = new Uint8Array(assertion.response.authenticatorData);
// Extract userHandle from resident credential
const userHandleLength = authenticatorData[53];
const userHandleStart = 54;
const userHandle = Buffer.from(
authenticatorData.slice(userHandleStart, userHandleStart + userHandleLength)
).toString('utf-8');
const user = await db.user.findUnique({
where: { id: userHandle },
include: { credentials: true },
});
if (!user) return res.status(401).json({ error: 'User not found' });
// Find matching credential and verify
const credential = user.credentials.find(
c => c.credentialId === assertion.credentialId
);
if (!credential) return res.status(401).json({ error: 'Credential not found' });
// Verify assertion... (same as above)
return res.json({ sessionToken: createSessionToken(user.id) });
}
// Generate options
const options = generateAuthenticationOptions(process.env.RP_ID);
res.json(options);
}
Device Binding and Passkey Sync
Device binding associates credentials with specific devices. Passkeys sync across devices via services like iCloud Keychain or Google Password Manager.
// Track device binding
model DeviceBinding {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
deviceId String // Hardware-derived ID
aaguid String // Authenticator GUID
deviceName String? // "iPhone 15 Pro"
isRoaming Boolean @default(false) // Synced across devices
lastAuthAt DateTime?
createdAt DateTime @default(now())
@@unique([userId, deviceId])
@@index([userId])
}
Node.js FIDO2 Libraries
Popular production libraries for server-side WebAuthn:
// Using @simplewebauthn/server (recommended)
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
// Registration
const regOptions = generateRegistrationOptions({
rpID: 'example.com',
rpName: 'Example App',
userID: userId,
userName: userEmail,
userDisplayName: userName,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
// Verification
const verified = await verifyRegistrationResponse({
response: credential,
expectedChallenge: storedChallenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
});
Fallback to Password
Never force passwordless overnight. Implement graceful fallbacks.
// Login page with WebAuthn + password fallback
export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false);
async function handleWebAuthnLogin() {
try {
// WebAuthn flow
} catch (err) {
setShowPassword(true); // Fall back to password
}
}
return (
<>
<button onClick={handleWebAuthnLogin}>Sign in with Passkey</button>
{showPassword && <PasswordForm />}
<p className="text-center text-sm">
Don't have a passkey? <button onClick={() => setShowPassword(true)}>Use password</button>
</p>
</>
);
}
Conclusion
WebAuthn eliminates the weakest link in authentication—the password. By implementing registration, attestation, authentication ceremonies, and resident credentials, you deliver both security and usability. Start with optional WebAuthn alongside passwords, then migrate users gradually. Use battle-tested libraries like @simplewebauthn/server to avoid cryptographic pitfalls.
The future is passwordless. Build it today.