Published / 5 min
Passkeys: toward a passwordless sign-in
What WebAuthn and passkeys change, how registration and login work, and what teams must plan for when migrating authentication.
Passwords get reused, leaked, and typed into fake pages. Adding an SMS code helps in some cases, but it does not remove all friction or all phishing. Passkeys propose a different contract: a device keeps a private key and a server stores a public key; signing in means proving possession of the key rather than remembering a string.
What is a passkey, without magic promises?
A passkey is a credential based on WebAuthn/FIDO. On creation, the authenticator generates a key pair unique to the service. The private key remains under the control of the device or its sync provider; the public key is registered on your server. To use it, a person unlocks their authenticator with a PIN, biometrics, or another local method. Their fingerprint is not sent to your backend.
The browser binds the operation to the site's origin and Relying Party ID (RP ID). A phishing page on another domain cannot simply request the same credential as the real site. That provides phishing resistance and removes the problem of reusing a password across services.
But "passwordless" does not mean "risk-free": people can lose access to devices, account recovery can be poorly designed, sessions can be stolen after login, and compromised accounts can still have excessive permissions.
The complete flow, in two moments
Registration: your server authenticates the person through an existing method, generates a random, single-use challenge and creation options. The browser asks the authenticator to create a credential. The server verifies the response —challenge, origin, RP ID, and user verification according to policy— then stores the identifier, public key, and necessary metadata.
Authentication: the server issues another challenge. The authenticator signs it with the private key and returns proof. The server checks signature, challenge, origin, and RP ID against the stored credential; only then does it create a session. Never trust verified: true sent by the browser: the real verification happens on the server.
The native API uses navigator.credentials.create() and navigator.credentials.get(), but handling binary formats and correctly verifying responses takes care. For a real implementation, use a maintained WebAuthn library on both ends. This snippet uses @simplewebauthn/browser as a reference; it is not a dependency of this portfolio:
import { startRegistration } from "@simplewebauthn/browser";
async function createPasskey() {
const optionsResponse = await fetch("/api/passkeys/registration/options", {
method: "POST", credentials: "same-origin",
});
if (!optionsResponse.ok) throw new Error("Could not create options");
const optionsJSON = await optionsResponse.json();
const credential = await startRegistration({ optionsJSON });
const verification = await fetch("/api/passkeys/registration/verify", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
});
if (!verification.ok) throw new Error("Registration not verified");
}
The server must associate those options with the session that began registration, not with an arbitrary user ID sent by the client. An essential piece of verification looks like this (fragment only: production storage, sessions, and error handling are omitted):
import { verifyRegistrationResponse } from "@simplewebauthn/server";
const challenge = await consumeChallenge(session.id); // read and spend once
const result = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challenge,
expectedOrigin: "https://example.com",
expectedRPID: "example.com",
requireUserVerification: true,
});
if (!result.verified || !result.registrationInfo) throw new Error("Invalid registration");
await saveCredential(session.userId, result.registrationInfo.credential);
Login follows equivalent steps with startAuthentication and verifyAuthenticationResponse. Verification looks up the stored public key, rejects expired or reused challenges, and updates credential metadata according to the library. Do not copy a UI fragment and call it "authentication done" without the backend.
Migration tip: offer passkey enrollment after a successful sign-in and allow multiple credentials per account. A second passkey and a clear recovery path keep a new phone from becoming a lost account.
Decisions teams often forget
- Recovery: decide what happens when someone loses every device. An insecure recovery link can undo the advantages of passkey sign-in.
- Synced versus device-bound keys: both exist; your risk policy may need to distinguish scenarios. Do not assume biometrics are always present.
- Sessions afterward: a passkey protects sign-in, not stolen cookies. Keep CSRF protection where appropriate, secure cookies, and session revocation.
- Origin and deployment: WebAuthn requires a secure context (
https, apart from development exceptions), the right RP ID, and consistent domain configuration. - Fallback: migrate gradually without committing to passwords forever; measure usage, errors, and recovery before retiring old methods.
Goodbye to every password?
For many products, that can be the destination. For each organization, the route is a product and security decision: understandable enrollment, multiple devices, robust recovery, and a backend that verifies every challenge. Start by offering passkeys alongside the current method; once the flow is proven, you can reduce dependence on passwords without merely moving the problem to support.
Sources: passkeys.dev: What are passkeys? · WebAuthn on MDN · SimpleWebAuthn.