Back to Blog
Security15 min readJun 2026

Passkeys and WebAuthn: Passwordless Auth, Explained

Passwords get phished, leaked, and reused. Passkeys replace them with public-key cryptography baked into the browser. Here is how WebAuthn actually works, the ceremonies, the crypto, and how to add it to your app.

PasskeysWebAuthnFIDO2Authentication
SB

Sri Balaji

Founder

On this page

The problem passwords can't solve

Who this is for

Frontend and backend developers who keep hearing 'passkeys' and 'WebAuthn' but have never wired them up. You know what a password and a one-time code are. By the end you'll understand the cryptography, the two ceremonies, and have copy-pasteable client and server code. No prior crypto knowledge required.

Every password system shares one fatal flaw: there is a secret the user knows and the server stores. That single fact is the root of phishing, credential stuffing, database breaches, and password reuse. You can bolt on a TOTP app to slow attackers down, but a convincing fake login page can capture the password *and* the six-digit code in real time and replay both before they expire.

Passkeys remove the shared secret entirely. Instead of something you know, authentication becomes something your device *holds and proves*, a private key that never leaves the device and is cryptographically bound to the exact site you registered on. This article explains FIDO2 and WebAuthn from first principles, walks the registration and authentication ceremonies, and shows the actual code. If you're shaky on the difference between proving who you are and what you're allowed to do, read authentication vs authorization first.

What a passkey actually is

A passkey is a public/private key pair created on your device, where the private key never leaves and the public key is handed to the website, so the only way to log in is to prove possession of a key that nothing on the server side can leak.

WebAuthn is the browser API (a W3C standard) that lets a web page ask the device to create and use these keys. FIDO2 is the umbrella term covering WebAuthn plus CTAP, the protocol that lets a browser talk to an external security key over USB, NFC, or Bluetooth. The website that wants to authenticate you is called the relying party (RP).

A shared secret PIN written on a sticky note both you and the bank keepA password: the same value lives in your head and in the server's database
A physical house key cut to one specific lock, the key stays in your pocketA private key bound to one origin, stored in the device's secure hardware
The lock on your door that anyone can see but only your key opensThe public key, stored on the server, that only your private key can satisfy
Showing the locksmith your key fits without handing it overSigning a fresh challenge to prove possession without revealing the key
A password is a copy of a secret you both hold. A passkey is a lock-and-key pair where only you keep the key.

Platform vs roaming authenticators

An authenticator is the thing that holds the private key and performs the crypto. There are two flavours. A platform authenticator is built into the device, Face ID / Touch ID on Apple, the fingerprint sensor and screen lock on Android and Windows Hello. A roaming (cross-platform) authenticator is a separate physical device like a YubiKey that you plug in or tap. Platform authenticators give you the smooth 'just look at your phone' experience; roaming keys are portable across machines and favored for high-security and shared-device scenarios.

The registration ceremony, drawn out

Registration (the spec calls it attestation) is how a brand-new passkey gets created and its public key handed to your server. The browser sits in the middle, brokering between your server and the authenticator, it is the only party that gets to confirm which origin is actually asking, which is what makes the whole thing phishing-resistant.

1. challenge + options2. create()3. verify presence4. public key + attestation5. attestation response6. store public key
User

Touch ID / PIN

Browser

navigator.credentials

Authenticator

Secure hardware

Relying Party

Your server

Credential store

Public key + id

WebAuthn registration: the server issues a challenge, the authenticator mints a key pair, and only the public key travels back.

  1. 1

    Server generates a challenge

    Your backend creates a random, single-use challenge plus registration options (the RP id, user handle, and which authenticator types are allowed) and sends them to the page.

  2. 2

    Browser calls the authenticator

    The page passes those options to navigator.credentials.create(). The browser stamps in the real origin it is running on, the page can't fake this.

  3. 3

    User proves presence

    The authenticator asks for a biometric or PIN (user verification) so a key isn't minted silently.

  4. 4

    Key pair is minted

    The authenticator generates a new private/public key pair bound to this RP id. The private key stays locked in hardware forever.

  5. 5

    Public key returns

    The browser sends back the public key, a credential id, and signed client data (including the origin and challenge), the attestation object.

  6. 6

    Server verifies and stores

    The backend checks the challenge matches, the origin and RP id are correct, then stores the public key + credential id against the user. No secret is ever stored.

Attestation vs assertion

Registration produces an **attestation**, a statement about a newly created credential (and optionally what kind of authenticator made it). Login produces an **assertion**, a fresh signature proving you control an already-registered credential. Same crypto, different ceremony: create() attests, get() asserts.

Why passkeys beat passwords + TOTP

The headline win is phishing resistance, and it isn't a policy or a heuristic, it's structural. The signature an authenticator produces is bound to the origin the browser reports. A passkey created for your-bank.com simply will not produce a valid signature for your-bank.evil.com, because the browser refuses to use it there. There is no code the user could be tricked into pasting.

PropertyPasswordPassword + TOTPPasskey
Phishing-resistant?NoNo, codes are phishableYes, bound to origin
Replayable if intercepted?Yes, until changedYes, within ~30s windowNo, challenge is single-use
Secret stored on server?Yes (hashed)Yes + shared TOTP seedNo, only a public key
Breach exposes credentials?YesYesNo, public key is useless alone
Reuse across sites?Common, dangerousCommonImpossible, keys are per-site
Login frictionType secretType secret + codeBiometric / tap
Three approaches, three very different threat profiles.

Notice the third row: even a full database dump leaks nothing useful, because a public key can verify signatures but cannot create them. Compare that to a password breach, where every hashed secret is now a cracking target, or a TOTP seed leak, which lets an attacker generate valid codes forever. For the broader picture of where login state lives after authentication, see authentication and sessions in backends.

Wiring it up: client and server

Here's the authentication (assertion) ceremony in code. The flow mirrors registration: server issues a challenge, the browser asks the authenticator to sign it, the server verifies the signature against the stored public key. We'll use the @simplewebauthn libraries, which handle the fiddly byte encoding.

client/register.ts
typescript
// 1. REGISTRATION, ask the device to create a passkey
import { startRegistration } from "@simplewebauthn/browser";

async function registerPasskey() {
  // Server returns challenge + options (PublicKeyCredentialCreationOptions)
  const options = await fetch("/api/register/start").then((r) => r.json());

  // Browser + authenticator do the work; user is prompted for biometric.
  // Internally this calls navigator.credentials.create({ publicKey: options }).
  const attestation = await startRegistration({ optionsJSON: options });

  // Send the attestation back for the server to verify + store.
  await fetch("/api/register/finish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(attestation),
  });
}
client/login.ts
typescript
// 2. AUTHENTICATION, prove you hold an existing passkey
import { startAuthentication } from "@simplewebauthn/browser";

async function loginWithPasskey() {
  // Server returns a fresh single-use challenge.
  const options = await fetch("/api/login/start").then((r) => r.json());

  // Wraps navigator.credentials.get({ publicKey: options }).
  // The authenticator signs the challenge with the private key.
  const assertion = await startAuthentication({ optionsJSON: options });

  // Server verifies the signature against the stored public key.
  const res = await fetch("/api/login/finish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(assertion),
  });
  return res.ok; // session cookie set on success
}
server/login.ts
typescript
// 3. SERVER, verify the assertion (@simplewebauthn/server)
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";

const rpID = "example.com";
const origin = "https://example.com";

// START: issue a challenge, remember it server-side (in the session).
export async function loginStart(session: Session) {
  const options = await generateAuthenticationOptions({ rpID });
  session.challenge = options.challenge; // single-use, expires fast
  return options;
}

// FINISH: confirm the signature came from the stored public key.
export async function loginFinish(session: Session, body: AuthResponse) {
  const credential = await db.getCredentialById(body.id);

  const verification = await verifyAuthenticationResponse({
    response: body,
    expectedChallenge: session.challenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential, // { id, publicKey, counter }
  });

  if (!verification.verified) throw new Error("Bad assertion");

  // Persist the new signature counter to detect cloned authenticators.
  await db.updateCounter(body.id, verification.authenticationInfo.newCounter);
  return startSession(credential.userId);
}

Everything rides on HTTPS

WebAuthn refuses to run over plain HTTP (localhost is the only exception). The origin binding that makes passkeys phishing-resistant is meaningless if the connection itself can be tampered with. If TLS is fuzzy, read [HTTPS, TLS, and encryption basics](/blog/https-tls-and-encryption-basics).

Syncing: how passkeys survive a lost phone

The obvious objection: 'if the private key never leaves my device, what happens when I drop my phone in a lake?' The answer is synced passkeys. Modern platform authenticators back the private key up, encrypted end-to-end, to the platform's credential cloud: iCloud Keychain on Apple, Google Password Manager on Android and Chrome, and similar for Microsoft and third-party managers like 1Password.

  • Synced passkeys roam across all your devices signed into the same account. Buy a new phone, sign into iCloud, and your passkeys are there. This is the default for consumer apps.
  • Device-bound passkeys never leave the hardware (a YubiKey, or platform keys with sync disabled). More secure, no recovery if lost, used for high-assurance enterprise and admin accounts.
  • The sync provider only ever holds encrypted key material it cannot read; the keys are decrypted on-device behind your device unlock.

For your server, the sync model is mostly invisible, you still just store public keys. But it's why you should let users register multiple passkeys and keep a recovery path: a user might add a phone passkey and a hardware key for redundancy.

Common mistakes that cost hours

  1. Mismatched RP id. The RP id must be the site's registrable domain (example.com), not a full URL and not a subdomain you serve from. Get this wrong and create()/get() throw a cryptic SecurityError.
  2. Reusing or not storing the challenge. Each ceremony needs a fresh, single-use challenge held server-side and compared on finish. Skipping it opens you to replay attacks, the whole point of the challenge.
  3. Trusting client-sent origin or challenge. Always verify expectedOrigin and expectedChallenge on the server. Never let the client tell you what they should be.
  4. Ignoring the signature counter. The counter helps detect cloned authenticators. Persist newCounter after every login; a counter that goes backwards is a red flag.
  5. Storing only one passkey per user. With no second credential and no recovery flow, a lost device locks the user out. Support multiple credentials.
  6. Testing over HTTP on a real domain. WebAuthn only runs on secure contexts; localhost works for dev, but staging on plain HTTP will silently fail.

Takeaways

The whole article in seven lines

  • A passkey is a public/private key pair; the private key never leaves your device, only the public key reaches the server.
  • WebAuthn is the browser API; FIDO2 = WebAuthn + CTAP; the website is the 'relying party'.
  • Registration = attestation (create a key, send the public half). Login = assertion (sign a fresh challenge).
  • Phishing resistance is structural: signatures are bound to the origin the browser reports, so fake sites can't use them.
  • There is no shared secret, a database breach leaks only useless public keys.
  • Synced passkeys (iCloud Keychain / Google Password Manager) roam across devices; device-bound keys stay on hardware.
  • On the server: issue a single-use challenge, verify origin + RP id + signature, persist the counter.

Where to go next

Passkeys answer 'who are you'. The rest of an auth system decides what happens next, sessions, tokens, and permissions. These build directly on what you just learned.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.