October 24, 2025
Learn how passkeys use public key cryptography and WebAuthn to replace passwords. See registration, login flow, risks, code, and best practices for rollout.
Khaled Hassan

Passwords fail often. Users forget them. Attackers phish them. Breached databases leak them. The result is lockouts, fraud, and high support cost. Passkeys promise a better path. They remove shared secrets from servers and replace them with public key cryptography tied to the user’s device. The login becomes a quick local check, a fingerprint or face scan or PIN, then a signed challenge to the site. No typing. No SMS codes. No phishable links.
In this guide, you will learn how passkeys work end to end. We start with plain definitions, then walk the full flow for registration and sign in. You will see the moving parts, authenticators, relying parties, key pairs, and what each one does. We compare validation and verification since teams often confuse those steps. We cover risks and pitfalls, such as RP ID mismatches and device loss, with fixes that you can ship today. We include code that you can drop into a small demo, plus a mini case study from the field. Finally, we share best practices that map to real production, rate limits, timeouts, error paths, and privacy notices. If you want a clear answer to “how do passkeys work,” plus a path to deploy, this article is for you.
Sources for claims and figures are cited inline. For example, WebAuthn Level 3 defines the API and scoping rules, and platform vendors document passkey security and recent updates.
Passkeys are FIDO based credentials that replace passwords. A site stores a public key. The user’s device stores a private key and protects it with a local unlock method, such as biometrics or a PIN. Sign in is a signed challenge, not a shared secret.
We will use the WebAuthn terms: relying party (your site), authenticator (the device or security key), and user.
Registration
navigator.credentials.create() request with parameters such as RP ID, user handle, and acceptable algorithms. W3CAuthentication
navigator.credentials.get() with the RP ID and allow list.Reference, WebAuthn Level 3 formalizes creation and use of public key credentials, scoped to the RP and origin. W3C
[User] unlocks device
↓ local biometrics or PIN
[Authenticator] creates key pair or signs challenge
↓
[Browser WebAuthn] packages response
↓ HTTPS
[Relying Party] validates or verifies and updates session
Breakdown of the core objects you will see in code and logs.
example.com. Must match the origin rules in WebAuthn. Purpose, prevent phishing and cross site reuse. W3Cid), a stable byte array that maps to your account row. Purpose, link multiple credentials to one user.Pseudo example, server record
{
"userHandle": "01F4C2...",
"rpId": "example.com",
"credentials": [
{
"credId": "A1b2C3...",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkq...\n-----END PUBLIC KEY-----",
"signCount": 12,
"addedAt": "2025-10-24T12:00:00Z",
"attestation": "none"
}
]
}
Teams mix these terms. Keep them separate.
| Aspect | Validation, registration | Verification, authentication |
|---|---|---|
| Goal | Trust the new credential | Trust the current login |
| Inputs | Attestation, public key, challenge, origin | Challenge, origin, authenticator data, signature |
| Output | Store credential, link to user | Issue session or token |
| Typical failure | RP ID mismatch, bad attestation | Replay, wrong origin, bad signature |
Specification, WebAuthn defines both the creation ceremony and assertion ceremony, with conformance requirements for RP ID scope and origin checks.
example.com, do not serve authentication from auth.example.net. Fix, set rp.id, cookies, and origins to a consistent parent domain, or register separate credentials per domain. W3CrpId, or redirect to a first party page for the ceremony. W3Ctimeout values, and show cancel and retry paths. Microsoft’s guidance for phishing resistant passwordless provides planning tips. Microsoft LearnCallout note, header size
Do not stuff binary WebAuthn responses into headers. They are larger than typical limits. Use JSON bodies over HTTPS. Keep challenge payloads small. This avoids 413 or 431 errors on some CDNs and WAFs.
Below is a thin, working flow that you can adapt. It uses WebAuthn with a typical Node backend. Code examples are short and valid.
passkeys-demo/
├── server/
│ ├── index.js
│ └── verify.js
├── web/
│ ├── register.js
│ └── signin.js
└── README.md
1) Server, create registration options
// server/index.js
import express from "express";
import crypto from "crypto";
const app = express();
app.use(express.json());
const rpId = "example.com";
const users = new Map(); // userHandle -> { credentials: [] }
app.post("/register/options", (req, res) => {
const { userHandle, username } = req.body;
const challenge = crypto.randomBytes(32);
res.json({
rp: { id: rpId, name: "Example Inc." },
user: {
id: Buffer.from(userHandle, "hex"),
name: username,
displayName: username
},
challenge: challenge.toString("base64url"),
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
attestation: "none",
timeout: 60000
});
});
2) Client, call navigator.credentials.create
// web/register.js
async function register(userHandle, username) {
const opts = await fetch("/register/options", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ userHandle, username })
}).then(r => r.json());
opts.challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
opts.user.id = Uint8Array.from(opts.user.id.data || opts.user.id); // handle Buffer serialization
const cred = await navigator.credentials.create({ publicKey: opts });
// Send to server for validation and storage
const payload = {
id: cred.id,
rawId: Array.from(new Uint8Array(cred.rawId)),
response: {
clientDataJSON: Array.from(new Uint8Array(cred.response.clientDataJSON)),
attestationObject: Array.from(new Uint8Array(cred.response.attestationObject))
},
type: cred.type
};
await fetch("/register/verify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
}
3) Server, validate and store public key
Use a well tested library in production. Pseudo code below shows the checks that must pass, challenge, origin, RP ID, and attestation policy.
// server/verify.js
import { validateRegistrationResponse } from "@simplewebauthn/server"; // example library
app.post("/register/verify", async (req, res) => {
const expectedOrigin = "https://example.com";
const expectedRPID = rpId;
const verification = await validateRegistrationResponse({
response: req.body,
expectedOrigin,
expectedRPID,
requireUserVerification: true
});
if (!verification.verified) return res.status(400).json({ ok: false });
const { credentialID, credentialPublicKey, counter, userHandle } = verification.registrationInfo;
const record = users.get(userHandle) || { credentials: [] };
record.credentials.push({ credId: Buffer.from(credentialID).toString("base64url"), publicKey: credentialPublicKey, signCount: counter });
users.set(userHandle, record);
res.json({ ok: true });
});
4) Authentication options and navigator.credentials.get
// server/index.js
app.post("/auth/options", (req, res) => {
const { userHandle } = req.body;
const user = users.get(userHandle);
if (!user) return res.status(404).end();
const challenge = crypto.randomBytes(32);
const allowCredentials = user.credentials.map(c => ({
type: "public-key",
id: Buffer.from(c.credId, "base64url")
}));
res.json({ challenge: challenge.toString("base64url"), rpId, allowCredentials, userVerification: "required", timeout: 30000 });
});
// web/signin.js
async function signIn(userHandle) {
let opts = await fetch("/auth/options", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ userHandle }) }).then(r => r.json());
opts.challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
opts.allowCredentials = opts.allowCredentials.map(c => ({ ...c, id: Uint8Array.from(c.id.data || c.id) }));
const assertion = await navigator.credentials.get({ publicKey: opts });
const payload = {
id: assertion.id,
rawId: Array.from(new Uint8Array(assertion.rawId)),
response: {
clientDataJSON: Array.from(new Uint8Array(assertion.response.clientDataJSON)),
authenticatorData: Array.from(new Uint8Array(assertion.response.authenticatorData)),
signature: Array.from(new Uint8Array(assertion.response.signature)),
userHandle: assertion.response.userHandle ? Array.from(new Uint8Array(assertion.response.userHandle)) : null
},
type: assertion.type
};
await fetch("/auth/verify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
}
// server/verify.js
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
app.post("/auth/verify", async (req, res) => {
const expectedOrigin = "https://example.com";
const expectedRPID = rpId;
// Look up public key by credId
const credId = req.body.id;
const user = [...users.values()].find(u => u.credentials.find(c => c.credId === credId));
if (!user) return res.status(404).end();
const cred = user.credentials.find(c => c.credId === credId);
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedOrigin,
expectedRPID,
authenticator: { credentialPublicKey: cred.publicKey, credentialID: Buffer.from(cred.credId, "base64url"), counter: cred.signCount }
});
if (!verification.verified) return res.status(401).json({ ok: false });
cred.signCount = verification.authenticationInfo.newCounter;
res.json({ ok: true, session: "new-session-id" });
});
Platform notes
HttpOnly, Secure, and SameSite=Lax or Strict. Set short session lifetimes and refresh tokens with rotation.userVerification: "required" in both ceremonies.Fits well
Avoid or use alternatives
Alternatives
For deeper help, see DeepStrike’s penetration testing services to pressure test authentication and recovery paths, and to model phishing resistance in the real world. FIDO Alliance
Internal link, learn more about security testing at DeepStrike:
Passkeys remove passwords and their failures, phishing, reuse, and weak secrets. They shift trust to strong cryptography that is bound to your site and to the user’s device. The login becomes a short local unlock and a signed challenge. Teams get higher success rates, faster sign ins, and a simpler stack. The rollout does need care, RP ID scope, recovery planning, and clear UX. Start with a hybrid phase, measure success, and expand to admin consoles first. Modern platforms document policy and deployment steps, and the WebAuthn Level 3 spec shows exactly what to validate and verify. If you need help pressure testing your rollout, consider a focused engagement to simulate attacks and recovery edge cases. Learn more at DeepStrike’s penetration testing services, then build a plan and ship.
Are passkeys the same as FIDO2 or WebAuthn?
Passkeys are user friendly packaging of FIDO2 WebAuthn credentials. Same crypto, better UX. FIDO Alliance
Do I still need passwords?
You can keep them as a fallback. The goal is to phase them out as device support grows. Many platforms allow both during transition. The LastPass Blog
What about cross device use?
Platform managers sync passkeys with end to end encryption. Apple and Google shipped improvements in 2025 to make this smoother. Apple Developer
Are passkeys phishing resistant?
Yes. Credentials are bound to the origin. A fake site cannot produce a valid signature that matches your RP ID and origin checks. W3C
How fast are passkeys in practice?
Public data shows higher success and faster sign ins at large scale, including Google reporting 30 percent higher success and 20 percent faster sign ins. FIDO Alliance
Do they work on Windows, macOS, iOS, and Android?
Yes, all major vendors support passkeys and document setup and policy

Stay secure with DeepStrike penetration testing services. Reach out for a quote or customized technical proposal today
Contact Us