← Back to overview

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.

Tech Stack

SimpleWebAuthn

Server & browser libraries for WebAuthn

Express.js

Minimal Node.js web framework

SQLite

Lightweight database (swap for Postgres, etc.)

express-session

Session management for challenges

Database Schema

Only two tables are needed. Note: no password hashes — we only store public keys.

Users

ColumnTypePurpose
idTEXTPrimary key (UUID)
display_nameTEXTUser's display name
created_atDATETIMEAccount creation time

Credentials (Passkeys)

ColumnTypePurpose
idINTEGERPrimary key
user_idTEXTForeign key to users
credential_idTEXTBase64-encoded credential ID
public_keyTEXTBase64-encoded public key
counterINTEGERSignature counter (replay protection)
nameTEXTUser-friendly name for the passkey
transportsTEXTJSON 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.

Server Code

Dependencies

// package.json
{
  "dependencies": {
    "@simplewebauthn/server": "^10.0.0",
    "express": "^4.18.2",
    "express-session": "^1.17.3",
    "better-sqlite3": "^9.4.3"
  }
}

Configuration

// 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

Registration (Sign Up)

// 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
  );
}

Authentication (Sign In)

// 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;
}

Browser Code

// 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),
});

Key Implementation Notes

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.

Resources

Lines of Code

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