Skip to main content
Ganesh Joshi
Back to Blogs

WebAuthn and passkeys: passwordless auth for the web

February 16, 20264 min read
Tutorials
Security or authentication code on screen

Passkeys are replacing passwords. They use cryptographic credentials backed by device biometrics or security keys—no password to remember, type, or steal. WebAuthn is the W3C standard that makes this possible. Major platforms (Apple, Google, Microsoft) now support passkeys, making passwordless authentication mainstream.

Why passkeys?

Password problems Passkey solutions
Easily guessed Cryptographic, not guessable
Reused across sites Unique per site
Phishable Bound to origin, can't be phished
Stored on servers Only public key stored
Forgotten Device handles authentication

Passkeys eliminate entire categories of attacks.

How passkeys work

Registration

  1. User initiates registration
  2. Server generates a challenge and options
  3. Browser prompts for biometric/PIN
  4. Device creates a key pair
  5. Public key sent to server, private key stays on device
  6. Server stores public key + credential ID

Authentication

  1. User initiates login
  2. Server generates a challenge
  3. Browser prompts for biometric/PIN
  4. Device signs challenge with private key
  5. Server verifies signature with stored public key
  6. User authenticated

The private key never leaves the device. There's nothing to steal from the server.

Implementation

Server setup

Install SimpleWebAuthn:

npm install @simplewebauthn/server @simplewebauthn/browser

Registration (server)

// Generate registration options
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';

const rpName = 'My App';
const rpID = 'example.com';
const origin = `https://${rpID}`;

app.post('/api/auth/register/options', async (req, res) => {
  const user = await getUser(req.session.userId);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: user.id,
    userName: user.email,
    userDisplayName: user.name,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
    },
  });

  // Store challenge for verification
  await saveChallenge(user.id, options.challenge);

  res.json(options);
});

app.post('/api/auth/register/verify', async (req, res) => {
  const user = await getUser(req.session.userId);
  const expectedChallenge = await getChallenge(user.id);

  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
  });

  if (verification.verified) {
    await saveCredential(user.id, {
      credentialID: verification.registrationInfo.credentialID,
      credentialPublicKey: verification.registrationInfo.credentialPublicKey,
      counter: verification.registrationInfo.counter,
    });

    res.json({ verified: true });
  } else {
    res.status(400).json({ error: 'Verification failed' });
  }
});

Registration (client)

import { startRegistration } from '@simplewebauthn/browser';

async function registerPasskey() {
  // Get options from server
  const optionsRes = await fetch('/api/auth/register/options', {
    method: 'POST',
  });
  const options = await optionsRes.json();

  // Start WebAuthn registration
  const credential = await startRegistration(options);

  // Send credential to server for verification
  const verifyRes = await fetch('/api/auth/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credential),
  });

  const result = await verifyRes.json();
  if (result.verified) {
    console.log('Passkey registered!');
  }
}

Authentication (server)

import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';

app.post('/api/auth/login/options', async (req, res) => {
  const { email } = req.body;
  const user = await getUserByEmail(email);
  const credentials = await getUserCredentials(user.id);

  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials: credentials.map((cred) => ({
      id: cred.credentialID,
      type: 'public-key',
    })),
    userVerification: 'preferred',
  });

  await saveChallenge(user.id, options.challenge);

  res.json(options);
});

app.post('/api/auth/login/verify', async (req, res) => {
  const user = await getUserByCredentialID(req.body.id);
  const credential = await getCredential(req.body.id);
  const expectedChallenge = await getChallenge(user.id);

  const verification = await verifyAuthenticationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    authenticator: {
      credentialID: credential.credentialID,
      credentialPublicKey: credential.credentialPublicKey,
      counter: credential.counter,
    },
  });

  if (verification.verified) {
    // Update counter for replay protection
    await updateCounter(credential.id, verification.authenticationInfo.newCounter);

    // Create session
    req.session.userId = user.id;

    res.json({ verified: true });
  } else {
    res.status(400).json({ error: 'Verification failed' });
  }
});

Authentication (client)

import { startAuthentication } from '@simplewebauthn/browser';

async function loginWithPasskey(email: string) {
  // Get options
  const optionsRes = await fetch('/api/auth/login/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  });
  const options = await optionsRes.json();

  // Authenticate
  const credential = await startAuthentication(options);

  // Verify
  const verifyRes = await fetch('/api/auth/login/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credential),
  });

  const result = await verifyRes.json();
  if (result.verified) {
    window.location.href = '/dashboard';
  }
}

Conditional UI (autofill)

Show passkeys in the password field:

const options = await generateAuthenticationOptions({
  rpID,
  allowCredentials: [], // Empty for discoverable credentials
  userVerification: 'preferred',
});

const credential = await startAuthentication(options, {
  conditionalMediation: true, // Enable autofill
});
<input
  type="text"
  name="email"
  autocomplete="username webauthn"
/>

Users see passkeys as an autofill option.

Security considerations

Requirement Implementation
HTTPS only WebAuthn requires secure context
Origin validation Verify expectedOrigin matches your domain
Challenge entropy Use crypto-random 32+ bytes
Counter validation Check counter increases to detect cloning
Credential storage Store only public key, credential ID, counter

Cross-device authentication

Passkeys can authenticate across devices:

  1. User starts login on laptop
  2. Browser shows QR code
  3. User scans with phone
  4. Phone authenticates with biometric
  5. Laptop receives confirmation

This works automatically with synced passkeys (iCloud Keychain, Google Password Manager).

Fallback strategies

During transition, support both methods:

// Check if passkeys are available
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();

if (available) {
  showPasskeyOption();
}

// Always show password option during transition
showPasswordOption();

Summary

Passkeys use WebAuthn to provide phishing-resistant, passwordless authentication. Device biometrics or security keys replace passwords. Use SimpleWebAuthn for implementation. Generate challenges on the server, verify responses server-side. Support conditional UI for seamless autofill. During transition, offer both passkeys and passwords. Passkeys are more secure and easier to use than passwords.

Frequently Asked Questions

Passkeys are cryptographic credentials stored on your device or in a synced vault. They replace passwords with biometric authentication (fingerprint, face) or a PIN. They're phishing-resistant and easier to use than passwords.

WebAuthn is the W3C standard API for creating and using credentials. Passkeys are a specific type of WebAuthn credential that can sync across devices. All passkeys use WebAuthn, but not all WebAuthn credentials are passkeys.

Yes. Passkeys are resistant to phishing (credentials are bound to the domain), credential stuffing (no reusable password to steal), and brute force attacks (private key never leaves device).

Chrome, Safari, Edge, and Firefox all support WebAuthn and passkeys. Platform support includes Windows Hello, macOS/iOS Touch ID and Face ID, and Android fingerprint.

Yes. Users can authenticate with a device PIN instead of biometrics. Hardware security keys (like YubiKey) also work with WebAuthn without biometrics.

Related Posts