This page details the actual implementation of passkey authentication used in this demo. Everything here is production-ready and can be adapted for any Node.js application.
Server & browser libraries for WebAuthn
Minimal Node.js web framework
Lightweight database (swap for Postgres, etc.)
Session management for challenges
Only two tables are needed. Note: no password hashes — we only store public keys.
| Column | Type | Purpose |
|---|---|---|
id | TEXT | Primary key (UUID) |
display_name | TEXT | User's display name |
created_at | DATETIME | Account creation time |
| Column | Type | Purpose |
|---|---|---|
id | INTEGER | Primary key |
user_id | TEXT | Foreign key to users |
credential_id | TEXT | Base64-encoded credential ID |
public_key | TEXT | Base64-encoded public key |
counter | INTEGER | Signature counter (replay protection) |
name | TEXT | User-friendly name for the passkey |
transports | TEXT | JSON array of supported transports |
Security note: Public keys are safe to store in plain text. Even if your database is compromised, attackers cannot use public keys to authenticate — they need the private key, which never leaves the user's device.
// package.json
{
"dependencies": {
"@simplewebauthn/server": "^10.0.0",
"express": "^4.18.2",
"express-session": "^1.17.3",
"better-sqlite3": "^9.4.3"
}
}
// These must match your domain
const rpName = 'Your App Name';
const rpID = 'yourdomain.com'; // Domain only, no protocol
const origin = 'https://yourdomain.com'; // Full origin with protocol
// 1. Generate registration options
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: new TextEncoder().encode(userId),
userName: displayName,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required', // Enables usernameless login
userVerification: 'preferred',
},
});
// Store challenge in session
req.session.challenge = options.challenge;
// 2. After user creates passkey, verify the response
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
// 3. Store the credential
if (verification.verified) {
const { credential } = verification.registrationInfo;
db.prepare(`
INSERT INTO credentials (user_id, credential_id, public_key, counter)
VALUES (?, ?, ?, ?)
`).run(
userId,
credential.id,
Buffer.from(credential.publicKey).toString('base64'),
credential.counter
);
}
// 1. Generate authentication options
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
// Empty allowCredentials = accept any discoverable credential
});
req.session.challenge = options.challenge;
// 2. After user authenticates, verify the response
const credential = db.prepare(
'SELECT * FROM credentials WHERE credential_id = ?'
).get(req.body.id);
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: Buffer.from(credential.credential_id, 'base64url'),
credentialPublicKey: new Uint8Array(
Buffer.from(credential.public_key, 'base64')
),
counter: credential.counter,
},
});
// 3. Update counter and create session
if (verification.verified) {
db.prepare('UPDATE credentials SET counter = ? WHERE id = ?')
.run(verification.authenticationInfo.newCounter, credential.id);
req.session.userId = credential.user_id;
}
// Load SimpleWebAuthn browser bundle
<script src="https://unpkg.com/@simplewebauthn/browser@10/dist/bundle/index.umd.min.js"></script>
// Registration
const options = await fetch('/api/register/start', { method: 'POST' });
const credential = await SimpleWebAuthnBrowser.startRegistration(options);
await fetch('/api/register/complete', {
method: 'POST',
body: JSON.stringify(credential),
});
// Authentication
const options = await fetch('/api/login/start', { method: 'POST' });
const credential = await SimpleWebAuthnBrowser.startAuthentication(options);
await fetch('/api/login/complete', {
method: 'POST',
body: JSON.stringify(credential),
});
Production considerations: For production, add rate limiting, use a production session store (Redis, etc.), implement proper CORS headers, and consider adding backup authentication methods during the transition period.
The complete server implementation for this demo is under 250 lines including comments, session management, and all CRUD operations for passkeys. The browser code is under 100 lines.
Passkey auth is simpler than password auth when you account for hashing, reset flows, and email verification.
← Back to overview