Skip to content
All essays
CraftMarch 27, 202418 min

Implementing Passkeys: The Passwordless Future of Authentication

Learn to implement passkeys (WebAuthn/FIDO2) for secure, passwordless authentication in your web applications

Ü
Ümit Uz
Mobile & Full Stack Developer

Passkeys represent the future of authentication, replacing passwords with cryptographic credentials stored on user devices. Built on the WebAuthn standard, passkeys provide superior security while improving user experience.

What Are Passkeys?

Passkeys are cryptographic credentials that replace passwords. They consist of:

  1. 1Private Key: Stored securely on user's device (TPM, Secure Enclave)
  2. 2Public Key: Stored on your server
  3. 3Biometric/PIN: Local authentication (fingerprint, Face ID)

Passkeys are phishing-resistant, never leave the user's device, and work across all platforms.

WebAuthn Fundamentals

Registration Flow

javascript
// Server generates registration options
async function registerStart(email) {
  const user = await db.users.findByEmail(email);

  const options = {
    challenge: base64url.encode(randomBuffer(32)),
    rp: {
      name: 'My App',
      id: 'example.com'
    },
    user: {
      id: base64url.encode(user.id),
      name: email,
      displayName: user.name
    },
    pubKeyCredParams: [
      { type: 'public-key', alg: -7 },   // ES256
      { type: 'public-key', alg: -257 }  // RS256
    ],
    authenticatorSelection: {
      authenticatorAttachment: 'platform',
      userVerification: 'preferred'
    },
    timeout: 60000,
    attestation: 'none'
  };

  // Save challenge to session
  await saveChallenge(user.id, options.challenge);

  return options;
}

// Client registers credential
async function registerFinish(email, credential) {
  const user = await db.users.findByEmail(email);
  const challenge = await getChallenge(user.id);

  // Verify challenge
  const expectedChallenge = challenge;

  const verification = await verifyRegistrationResponse({
    credential,
    expectedChallenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com'
  });

  if (verification.verified) {
    // Save credential to database
    await db.credentials.create({
      userId: user.id,
      credentialId: verification.registrationInfo.credentialID,
      publicKey: verification.registrationInfo.credentialPublicKey,
      counter: verification.registrationInfo.counter,
      transports: credential.response.transports
    });
  }

  return verification.verified;
}

Authentication Flow

javascript
// Server generates authentication options
async function loginStart(email) {
  const user = await db.users.findByEmail(email);
  const credentials = await db.credentials.findByUserId(user.id);

  const options = {
    challenge: base64url.encode(randomBuffer(32)),
    allowCredentials: credentials.map(cred => ({
      id: cred.credentialId,
      type: 'public-key'
    })),
    userVerification: 'preferred',
    timeout: 60000
  };

  // Save challenge
  await saveChallenge(email, options.challenge);

  return options;
}

// Client authenticates
async function loginFinish(email, credential) {
  const challenge = await getChallenge(email);
  const credRecord = await db.credentials.findById(credential.id);

  const verification = await verifyAuthenticationResponse({
    credential,
    expectedChallenge: challenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com',
    authenticator: {
      credentialID: credRecord.credentialId,
      credentialPublicKey: credRecord.publicKey,
      counter: credRecord.counter
    }
  });

  if (verification.verified) {
    // Update counter
    await db.credentials.update(credRecord.id, {
      counter: verification.authenticationInfo.newCounter
    });

    // Create session
    const session = await createSession(credRecord.userId);
    return session;
  }

  throw new Error('Authentication failed');
}

Client-Side Implementation

Registration with SimpleWebAuthn

javascript
import { startRegistration, finishRegistration } from '@simplewebauthn/browser';

async function registerPasskey() {
  // Get registration options from server
  const optionsResp = await fetch('/auth/register/start', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: 'user@example.com' })
  });

  const options = await optionsResp.json();

  // Start registration (shows browser UI)
  let registration;
  try {
    registration = await startRegistration(options);
  } catch (error) {
    console.error('Registration failed:', error);
    return;
  }

  // Send registration to server
  const verificationResp = await fetch('/auth/register/finish', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: 'user@example.com',
      credential: registration
    })
  });

  const verification = await verificationResp.json();

  if (verification.verified) {
    alert('Passkey registered successfully!');
  }
}

Authentication with SimpleWebAuthn

javascript
import { startAuthentication, finishAuthentication } from '@simplewebauthn/browser';

async function signInWithPasskey() {
  // Get authentication options
  const optionsResp = await fetch('/auth/login/start', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: 'user@example.com' })
  });

  const options = await optionsResp.json();

  // Start authentication
  let authentication;
  try {
    authentication = await startAuthentication(options);
  } catch (error) {
    console.error('Authentication failed:', error);
    return;
  }

  // Send authentication to server
  const verificationResp = await fetch('/auth/login/finish', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: 'user@example.com',
      credential: authentication
    })
  });

  const verification = await verificationResp.json();

  if (verification.verified) {
    // Store session token
    localStorage.setItem('token', verification.token);
    window.location.href = '/dashboard';
  }
}

Server-Side Implementation

Express.js Backend

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

// Registration endpoint
app.post('/auth/register/start', async (req, res) => {
  const { email } = req.body;

  const user = await db.users.findOrCreate({ email });

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

  // Save challenge
  await redis.set(
    `registration:${user.id}`,
    options.challenge,
    'EX',
    60
  );

  res.json(options);
});

app.post('/auth/register/finish', async (req, res) => {
  const { email, credential } = req.body;

  const user = await db.users.findByEmail(email);
  const challenge = await redis.get(`registration:${user.id}`);

  const verification = await verifyRegistrationResponse({
    response: credential,
    expectedChallenge: challenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com'
  });

  if (verification.verified) {
    await db.credentials.create({
      userId: user.id,
      credentialId: verification.registrationInfo.credentialID,
      publicKey: verification.registrationInfo.credentialPublicKey,
      counter: verification.registrationInfo.counter
    });

    res.json({ verified: true });
  } else {
    res.status(400).json({ verified: false });
  }
});

// Authentication endpoints
app.post('/auth/login/start', async (req, res) => {
  const { email } = req.body;

  const user = await db.users.findByEmail(email);
  const credentials = await db.credentials.findByUserId(user.id);

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

  await redis.set(
    `authentication:${email}`,
    options.challenge,
    'EX',
    60
  );

  res.json(options);
});

app.post('/auth/login/finish', async (req, res) => {
  const { email, credential } = req.body;

  const challenge = await redis.get(`authentication:${email}`);
  const credRecord = await db.credentials.findById(credential.id);

  const verification = await verifyAuthenticationResponse({
    response: credential,
    expectedChallenge: challenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com',
    authenticator: {
      credentialID: credRecord.credentialId,
      credentialPublicKey: credRecord.publicKey,
      counter: credRecord.counter
    }
  });

  if (verification.verified) {
    await db.credentials.update(credRecord.id, {
      counter: verification.authenticationInfo.newCounter
    });

    const token = jwt.sign({ userId: credRecord.userId }, SECRET);
    res.json({ verified: true, token });
  } else {
    res.status(401).json({ verified: false });
  }
});

Fallback and Recovery

Backup Codes

javascript
async function generateBackupCodes(userId) {
  const codes = [];
  for (let i = 0; i < 10; i++) {
    codes.push(crypto.randomBytes(4).toString('hex').toUpperCase());
  }

  await db.backupCodes.create({
    userId,
    codes: codes.map(code => ({
      code: await bcrypt.hash(code, 10),
      used: false
    }))
  });

  return codes;
}

async function verifyBackupCode(userId, code) {
  const backupCodes = await db.backupCodes.findByUserId(userId);

  for (const record of backupCodes) {
    if (await bcrypt.compare(code, record.code) && !record.used) {
      await db.backupCodes.markUsed(record.id);
      return true;
    }
  }

  return false;
}

Hybrid Authentication

javascript
async function signIn(email) {
  const user = await db.users.findByEmail(email);

  if (user.hasPasskey) {
    // Try passkey first
    return renderPasskeyAuth();
  } else {
    // Fallback to password
    return renderPasswordAuth();
  }
}

async function linkPasskeyToAccount(userId, credential) {
  // Verify credential
  const verification = await verifyRegistrationResponse(...);

  if (verification.verified) {
    await db.credentials.create({
      userId,
      credentialId: verification.registrationInfo.credentialID,
      publicKey: verification.registrationInfo.credentialPublicKey
    });

    await db.users.update(userId, { hasPasskey: true });
  }
}

Best Practices

  1. 1Always Verify Challenges: Prevent replay attacks
  2. 2Set Time Limits: Challenges should expire (60 seconds)
  3. 3Support Multiple Credentials: Allow users to register multiple devices
  4. 4Provide Fallback: Backup codes or password for account recovery
  5. 5User Verification: Require biometrics when possible
  6. 6HTTPS Only: WebAuthn requires secure context
  7. 7Clear UX: Guide users through the process
  8. 8Test on Devices: Passkeys work differently across platforms

Conclusion

Passkeys provide superior security and user experience compared to passwords. With proper implementation using WebAuthn and SimpleWebAuthn, you can offer passwordless authentication that's both secure and user-friendly. Start by offering passkeys as an option, then gradually make it the default.

Related essays

Next essay
Magic Links: Building Passwordless Authentication with Email