Passwords are fundamentally broken. Users reuse them across sites, phishing steals them daily, and even hashed databases get breached. Passkeys replace passwords entirely with cryptographic key pairs stored on the user’s device, authenticated with biometrics (fingerprint, face) or a device PIN. No password to remember, no password to steal.
How Passkeys Work
- Registration: The user’s device generates a public/private key pair. The public key is sent to your server. The private key never leaves the device.
- Authentication: Your server sends a random challenge. The device signs it with the private key (after biometric verification). Your server verifies the signature with the stored public key.
The private key is protected by the device’s secure enclave (TPM, Secure Enclave, Android Keystore). Even if your server is breached, attackers get only public keys — which are useless without the device.
WebAuthn API: Registration
// Client-side: create a new passkey
async function registerPasskey() {
// 1. Get registration options from your server
const response = await fetch('/api/auth/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'alice@example.com' }),
});
const options = await response.json();
// 2. Browser prompts user for biometric verification
const credential = await navigator.credentials.create({
publicKey: {
challenge: base64ToBuffer(options.challenge),
rp: {
name: 'My App',
id: 'example.com', // Must match your domain
},
user: {
id: base64ToBuffer(options.userId),
name: 'alice@example.com',
displayName: 'Alice Smith',
},
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256
{ alg: -257, type: 'public-key' }, // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform', // Device biometrics
residentKey: 'required', // Discoverable credential
userVerification: 'required', // Biometric required
},
timeout: 60000,
},
});
// 3. Send credential to server for storage
await fetch('/api/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64(credential.rawId),
response: {
attestationObject: bufferToBase64(
credential.response.attestationObject
),
clientDataJSON: bufferToBase64(
credential.response.clientDataJSON
),
},
type: credential.type,
}),
});
}
WebAuthn API: Authentication
// Client-side: authenticate with existing passkey
async function loginWithPasskey() {
// 1. Get authentication options from server
const response = await fetch('/api/auth/login/options', {
method: 'POST',
});
const options = await response.json();
// 2. Browser prompts for biometric verification
const assertion = await navigator.credentials.get({
publicKey: {
challenge: base64ToBuffer(options.challenge),
rpId: 'example.com',
allowCredentials: [], // Empty = discoverable (shows all passkeys)
userVerification: 'required',
timeout: 60000,
},
});
// 3. Send signed assertion to server
const result = await fetch('/api/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: assertion.id,
rawId: bufferToBase64(assertion.rawId),
response: {
authenticatorData: bufferToBase64(
assertion.response.authenticatorData
),
clientDataJSON: bufferToBase64(
assertion.response.clientDataJSON
),
signature: bufferToBase64(
assertion.response.signature
),
},
type: assertion.type,
}),
});
const session = await result.json();
// User is now authenticated!
}
Server-Side: Python Verification
# Using the py_webauthn library
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
ResidentKeyRequirement,
UserVerificationRequirement,
)
# Registration options
def get_registration_options(user):
options = generate_registration_options(
rp_id="example.com",
rp_name="My App",
user_id=user.id.encode(),
user_name=user.email,
user_display_name=user.name,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
)
# Store challenge in session for verification
session['challenge'] = options.challenge
return options
# Verify registration
def verify_registration(credential_data):
verification = verify_registration_response(
credential=credential_data,
expected_challenge=session['challenge'],
expected_origin="https://example.com",
expected_rp_id="example.com",
)
# Store credential for future authentication
store_credential(
user_id=current_user.id,
credential_id=verification.credential_id,
public_key=verification.credential_public_key,
sign_count=verification.sign_count,
)
Passkeys vs Passwords Comparison
| Aspect | Passwords | Passkeys |
|---|---|---|
| Phishing | Vulnerable (users type on fake sites) | Immune (domain-bound, cannot be phished) |
| Breach impact | Hashed passwords can be cracked | Public keys are useless to attackers |
| Reuse across sites | Common (80% of users) | Impossible (unique key per site) |
| User experience | Remember, type, reset | Touch fingerprint sensor |
| MFA needed | Yes (passwords alone are weak) | No (biometric + device is inherently 2FA) |
| Cross-device | Works everywhere | Synced via iCloud/Google (or use QR code) |
Progressive Enhancement Strategy
// Check if the browser supports WebAuthn
function supportsPasskeys(): boolean {
return window.PublicKeyCredential !== undefined &&
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function';
}
// Check if a platform authenticator is available
async function hasPlatformAuth(): Promise<boolean> {
if (!supportsPasskeys()) return false;
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
}
// Progressive enhancement:
// 1. Everyone gets username/password as baseline
// 2. If passkeys supported: show "Add Passkey" option in settings
// 3. On next login: offer "Sign in with Passkey" as primary option
// 4. Keep password as fallback for unsupported devices
Key Takeaways
- Passkeys are phishing-proof — the private key is domain-bound and never transmitted
- Passkeys are inherently two-factor: something you have (device) + something you are (biometric)
- Use progressive enhancement — offer passkeys alongside passwords, do not force them
- Store only public keys on your server — a breach exposes nothing usable
- Discoverable credentials (resident keys) enable username-less login — the user just touches their fingerprint sensor
- Major platforms support passkey sync: iCloud Keychain (Apple), Google Password Manager, Windows Hello
- Libraries handle the crypto: py_webauthn (Python), SimpleWebAuthn (Node.js), webauthn4j (Java)
Passkeys are the future of authentication, and the future is already here. Apple, Google, and Microsoft have committed to universal passkey support. Start with progressive enhancement — add passkey registration alongside existing password auth — and give your users the option to never type a password again.