Published on

Passkeys and WebAuthn — Replacing Passwords With Phishing-Resistant Authentication

Authors

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

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.