DEV Community

Spicy
Spicy

Posted on

Passkeys Under the Hood: What's Actually Happening When You Use Face ID to Log In

Every developer I know understands that passwords are broken. What fewer people have actually dug into is why passkeys fix this at the protocol level — and how surprisingly simple the WebAuthn API is to implement.

Here's the full picture: what's happening cryptographically, how the browser API works, and a quick comparison of the libraries worth using in production.


The Core Problem With Passwords (The Actual Technical One)

Passwords fail not because users pick weak ones. They fail because of how the authentication model works:

  1. User creates a password
  2. Server stores a hash of it
  3. User sends the password on every login
  4. Server hashes it and compares

Step 3 is the problem. The credential gets transmitted. That transmission can be intercepted (phishing), the hash can be cracked (breach), or the same credential works on other sites (credential stuffing). Every "password best practice" is mitigation, not a fix.

Passkeys change the model entirely.


How Passkeys Actually Work

Passkeys use asymmetric cryptography (FIDO2/WebAuthn). Here's the flow:

Registration:

  1. Server sends a random challenge
  2. Device generates a public/private key pair
  3. Private key stays on device (in Secure Enclave / TPM)
  4. Public key + signed challenge sent to server
  5. Server stores public key only

Authentication:

  1. Server sends a new random challenge
  2. User approves with biometric/PIN
  3. Device signs the challenge with private key
  4. Server verifies signature using stored public key
  5. Done — private key never leaves the device

The private key is never transmitted. Ever. The server never stores anything that can be used to impersonate the user. A phishing page that captures the signed challenge gets nothing reusable — challenges are single-use nonces.


The WebAuthn API in Practice

The browser API maps directly to that flow. Registration looks like this:

// Registration
const credential = await navigator.credentials.create({
  publicKey: {
    challenge: serverChallenge, // Uint8Array from your server
    rp: { name: "Your App", id: "yourapp.com" },
    user: {
      id: userId,           // Uint8Array, not a username
      name: "user@email.com",
      displayName: "User Name"
    },
    pubKeyCredParams: [
      { alg: -7, type: "public-key" },   // ES256 (preferred)
      { alg: -257, type: "public-key" }  // RS256 (fallback)
    ],
    authenticatorSelection: {
      residentKey: "required",        // Enables passkey sync
      userVerification: "required"    // Requires biometric/PIN
    },
    timeout: 60000,
    attestation: "none"
  }
});

// Send credential.response to your server for verification
Enter fullscreen mode Exit fullscreen mode

Authentication is simpler:

// Authentication
const assertion = await navigator.credentials.get({
  publicKey: {
    challenge: serverChallenge,
    rpId: "yourapp.com",
    userVerification: "required",
    timeout: 60000
  }
});

// Send assertion.response to your server for verification
Enter fullscreen mode Exit fullscreen mode

The server-side verification is where most devs reach for a library — parsing and verifying the CBOR-encoded attestation objects by hand is painful.


Library Comparison

Library Language Passkey Support Notes
SimpleWebAuthn Node.js ✅ Full Best DX, actively maintained
py_webauthn Python ✅ Full Duo Labs, solid
webauthn4j Java ✅ Full Spring integration available
go-webauthn Go ✅ Full Clean API
Hanko Hosted ✅ Managed Drop-in if you don't want to own the flow
Passage by 1Password Hosted ✅ Managed Generous free tier

For most teams: SimpleWebAuthn for Node, py_webauthn for Python. If you want to skip the implementation entirely and just own the UX, Hanko's self-hostable version is genuinely good.


The Two Things That Catch People Out

1. residentKey: "required" is what makes it a passkey

Without this, you're implementing a regular FIDO2 security key flow — the credential isn't stored on the device and won't sync to iCloud/Google. If you want the "tap Face ID to log in" experience, residentKey: "required" and userVerification: "required" are both mandatory.

2. Cross-device auth uses CTAP2 + BLE proximity

When a user authenticates on a desktop with their phone, the desktop and phone establish a BLE proximity check to prevent remote attacks, then the phone signs the challenge and sends the signature back via a relay server. The private key never leaves the phone. Worth understanding before you field questions about "is it safe to use my phone to log into someone else's computer."


What I'd Actually Implement First

If you're adding passkeys to an existing app: don't rip out passwords. Add passkeys as an additional auth method first. Let users opt in. The fallback matters — not every user has a device that supports passkeys smoothly yet, and some enterprise environments block biometric auth entirely.

The happy path is excellent. The failure modes are what you need to design for. Test especially: browser without platform authenticator support, iOS 15 (passkey sync requires iOS 16+), and password manager conflicts when users have both a passkey and a saved password for the same domain.


The consumer version of this — what passkeys mean for regular users who don't care about WebAuthn — is over at lucas8.com/passkeys-vs-passwords-security.

Top comments (0)