Passkeys are not magic: what WebAuthn actually does

Passkeys have been marketed as a simple concept — log in with your face or fingerprint, no password needed. That framing is accurate from a user perspective but obscures what is actually happening technically. Under the hood, passkeys are a specific deployment of WebAuthn (Web Authentication API), which is itself the browser binding for the FIDO2 specification. Understanding the mechanics matters when you are implementing support, debugging authentication failures, or evaluating the actual security properties being claimed.

The FIDO2 stack

FIDO2 is composed of two specifications that work together:

  • WebAuthn (W3C): The browser API specification. Defines navigator.credentials.create() for registration and navigator.credentials.get() for authentication, and the data structures they work with.
  • CTAP2 (FIDO Alliance): Client-to-Authenticator Protocol. Defines how the browser communicates with authenticators over USB, NFC, or Bluetooth when the authenticator is an external device (a YubiKey, for example).

A "passkey" specifically refers to a WebAuthn credential stored in a synced credential manager (iCloud Keychain, Google Password Manager, Windows Hello cloud sync). The fundamental cryptographic operations are identical to non-synced WebAuthn — the only difference is where the private key lives.

Authenticator types

The WebAuthn spec distinguishes two categories of authenticator:

Platform authenticators are built into the device: Touch ID on Mac, Face ID on iPhone, Windows Hello on a Windows machine, Android fingerprint sensor. The private key is generated and stored in the device's secure enclave or TPM (Trusted Platform Module). The key never leaves the secure element — biometric verification happens locally, and only the cryptographic result (a signature) is returned to the browser.

Roaming authenticators are external hardware tokens: YubiKey, Google Titan Key, etc. They communicate over USB-HID, NFC, or Bluetooth. The private key is stored in the authenticator's secure element. These are the original FIDO U2F tokens, and they support CTAP2 for WebAuthn compatibility.

When you register a passkey on your iPhone, you're using a platform authenticator. When Apple syncs that passkey to iCloud Keychain and it appears on your Mac, the private key has been synced (encrypted) via iCloud — this is what distinguishes a passkey from a traditional FIDO2 credential, where the key was non-extractable by design.

Credential creation (registration)

The registration ceremony has four phases: challenge generation, authenticator invocation, attestation, and server-side storage.

// 1. Server generates a registration challenge
// GET /auth/webauthn/register/begin
{
  "challenge": "base64url_random_32_bytes",  // unpredictable, single-use
  "rp": {
    "id": "yourdomain.com",    // Relying Party ID — must match origin
    "name": "Your App"
  },
  "user": {
    "id": "base64url_user_id",
    "name": "alice@example.com",
    "displayName": "Alice"
  },
  "pubKeyCredParams": [
    { "type": "public-key", "alg": -7  },   // ES256 (preferred)
    { "type": "public-key", "alg": -257 }   // RS256 (fallback for Windows Hello)
  ],
  "authenticatorSelection": {
    "authenticatorAttachment": "platform",   // prefer platform (passkey)
    "residentKey": "required",               // discoverable credential
    "userVerification": "required"           // biometric/PIN required
  },
  "timeout": 60000,
  "attestation": "none"  // 'none' is fine for most apps; 'direct' for enterprise
}
// 2. Browser invokes the authenticator
const credential = await navigator.credentials.create({
  publicKey: registrationOptions  // from server
});

// credential.response contains:
// - clientDataJSON: JSON with type, challenge, origin
// - attestationObject: CBOR-encoded authenticator data + attestation statement

// 3. Send to server for verification
const response = {
  id: credential.id,                   // base64url credential ID
  rawId: bufferToBase64url(credential.rawId),
  type: credential.type,               // "public-key"
  response: {
    clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
    attestationObject: bufferToBase64url(credential.response.attestationObject)
  }
};

Server-side verification of the attestation

// Node.js server-side registration verification
import { verifyRegistrationResponse } from '@simplewebauthn/server';

async function verifyRegistration(userId, response) {
  const expectedChallenge = await getStoredChallenge(userId);

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge,
    expectedOrigin: 'https://yourdomain.com',
    expectedRPID: 'yourdomain.com',
    requireUserVerification: true
  });

  if (!verification.verified) throw new Error('Registration verification failed');

  const { credentialID, credentialPublicKey, counter, credentialDeviceType } =
    verification.registrationInfo;

  // Store the credential — one user can have multiple
  await db.webauthnCredentials.create({
    userId,
    credentialId: credentialID,      // Buffer — store as bytea or base64
    publicKey: credentialPublicKey,  // COSE-encoded public key
    counter,                         // signature counter for clone detection
    deviceType: credentialDeviceType,  // 'platform' or 'multiDevice'
    transports: response.response.transports,
    createdAt: new Date()
  });
}

Authentication (assertion)

Authentication requires the authenticator to sign a server-supplied challenge with the private key corresponding to a previously registered credential.

// 1. Server issues authentication challenge
{
  "challenge": "base64url_new_random_32_bytes",
  "rpId": "yourdomain.com",
  "allowCredentials": [   // optional: hint to browser which credentials to offer
    { "type": "public-key", "id": "credentialId", "transports": ["internal"] }
  ],
  "userVerification": "required",
  "timeout": 60000
}

// 2. Browser gets assertion from authenticator
const assertion = await navigator.credentials.get({
  publicKey: authenticationOptions
});

// assertion.response contains:
// - clientDataJSON: { type: "webauthn.get", challenge, origin }
// - authenticatorData: rpIdHash + flags + signCount
// - signature: signature over (authenticatorData + SHA256(clientDataJSON))
// 3. Server verifies the assertion
import { verifyAuthenticationResponse } from '@simplewebauthn/server';

async function verifyAuthentication(response) {
  const credentialId = response.id;
  const storedCredential = await db.webauthnCredentials.findOne({ credentialId });
  if (!storedCredential) throw new Error('Credential not found');

  const expectedChallenge = await getStoredChallenge();

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: 'https://yourdomain.com',
    expectedRPID: 'yourdomain.com',
    authenticator: {
      credentialID: storedCredential.credentialId,
      credentialPublicKey: storedCredential.publicKey,
      counter: storedCredential.counter,
    },
    requireUserVerification: true
  });

  if (!verification.verified) throw new Error('Authentication verification failed');

  // Update the signature counter (detects cloned authenticators)
  await db.webauthnCredentials.update(
    { credentialId },
    { counter: verification.authenticationInfo.newCounter }
  );

  return storedCredential.userId;
}
The signature counter is a monotonically increasing integer that the authenticator increments with every signature. If your server receives a counter value lower than or equal to the stored value, the authenticator may have been cloned. For synced passkeys (where the credential is copied across devices by design), the counter is typically set to 0 and never incremented — this is expected behavior, not a security failure.

What "passkeys are synced" means technically

Traditional FIDO2 security was built on the premise that the private key is non-exportable and hardware-bound. A YubiKey credential cannot leave the YubiKey. This is the strongest security model but terrible for usability — lose your YubiKey, lose access.

Passkeys relax this by allowing platforms to sync encrypted credential material via their cloud services. When Apple syncs a passkey from your iPhone to your Mac via iCloud Keychain, the private key is encrypted under your iCloud account's end-to-end encryption keys, transmitted to Apple's servers, and decrypted on the destination device. Apple (or anyone without your Apple ID credentials) cannot read the key material in transit or at rest.

The security tradeoff is explicit: you gain account recovery and multi-device convenience, but you add your iCloud account as a trust dependency. An account takeover on your iCloud/Google/Microsoft account can expose your passkeys. This is generally still far more secure than passwords, but it is not the same security model as a non-extractable hardware key.

Cross-platform sync limitations

There is no cross-ecosystem passkey sync. A passkey created in iCloud Keychain on your iPhone does not sync to your Android phone. A Windows Hello passkey does not sync to your Mac. The sync is silo'd within each platform's credential manager.

FIDO Alliance has published a draft specification for "credential exchange" that would enable cross-platform import/export, but as of late 2025 it is not yet implemented in any major platform. For users who operate across ecosystems, the practical solution is to register multiple passkeys from different devices/platforms, or to register a roaming authenticator (YubiKey) as a portable credential.

RP ID and origin binding

WebAuthn binds credentials to a Relying Party ID (rpId), which must be a suffix of the origin's effective domain. A passkey registered on app.yourdomain.com with rpId: "yourdomain.com" can be used on any subdomain of yourdomain.com. It cannot be used on otherdomain.com. This origin binding is what makes WebAuthn phishing-resistant: a fake site at yourd0main.com cannot use credentials registered for yourdomain.com.

← Back to blog Try Bastionary free →