WebAuthn and Passkeys: Complete Developer Guide 2025 | MagicAuth
Blog
-
@import url("https://fonts.googleapis.com/css2?family=Charter:wght@400;700&family=Inter:wght@300;400;500;600;700&display=swap");
body {
font-family: "Charter", "Georgia", serif;
}
.font-sans {
font-family: "Inter", sans-serif;
}
/* Medium-style article typography */
.article-content {
font-size: 21px;
line-height: 1.58;
letter-spacing: -0.003em;
color: #242424;
}
.article-content h1 {
font-size: 2.5em;
line-height: 1.2;
margin: 1.5em 0 0.5em;
font-weight: 700;
}
.article-content h2 {
font-size: 2em;
line-height: 1.3;
margin: 1.5em 0 0.5em;
font-weight: 700;
}
.article-content h3 {
font-size: 1.5em;
line-height: 1.4;
margin: 1.5em 0 0.5em;
font-weight: 700;
}
.article-content p {
margin: 1.5em 0;
}
.article-content a {
color: inherit;
text-decoration: underline;
}
.article-content blockquote {
border-left: 3px solid #242424;
padding-left: 1.5em;
margin: 1.5em 0;
font-style: italic;
}
.article-content pre {
background: #f4f4f4;
padding: 1em;
border-radius: 4px;
overflow-x: auto;
font-family: "Courier New", monospace;
font-size: 0.85em;
line-height: 1.5;
}
.article-content code {
background: #f4f4f4;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: "Courier New", monospace;
font-size: 0.85em;
}
.article-content img {
max-width: 100%;
height: auto;
margin: 2em 0;
}
.article-content ul,
.article-content ol {
margin: 1.5em 0;
padding-left: 2em;
}
.article-content li {
margin: 0.5em 0;
}
.article-content strong {
font-weight: 700;
}
.article-content em {
font-style: italic;
}
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "WebAuthn and Passkeys: Complete Developer Guide 2025",
"description": "Technical guide to implementing WebAuthn and passkeys with production-ready code examples.",
"image": "https://images.unsplash.com/photo-1614064641938-3bbee52942c7?w=800",
"author": {
"@type": "Organization",
"name": "MagicAuth",
"url": "https://magicauth.app"
},
"publisher": {
"@type": "Organization",
"name": "MagicAuth",
"logo": {
"@type": "ImageObject",
"url": "https://magicauth.app/logo.png"
}
},
"datePublished": "2025-11-26",
"dateModified": "2025-12-02",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://blog.magicauth.app/articles/webauthn-passkeys-developer-guide-2025.html"
}
}
**
Browser Support and Platform Compatibility
Since all major browsers in 2025 support WebAuthn,
developers can confidently integrate it without worrying
about compatibility issues. Here's the current
landscape:
Chrome 90+**: Full WebAuthn Level 2
support, including conditional UI and autofill
integration
-
**Safari 14+**: Native passkey support
in iCloud Keychain with cross-device sync
-
**Firefox 60+**: WebAuthn support with
CTAP2 protocol for external authenticators
-
**Edge 90+**: Windows Hello integration
plus cross-device passkey support
Starting with Chrome 133 (January 2025), the
`getClientCapabilities()` WebAuthn API helps
developers determine which authentication features are
supported by a browser. By calling
`PublicKeyCredential.getClientCapabilities(), you can retrieve a list of supported capabilities and
adapt authentication workflows accordingly:
// Feature detection for WebAuthn capabilities
if (window.PublicKeyCredential) {
PublicKeyCredential.getClientCapabilities()
.then(capabilities => {
console.log('Supported features:', capabilities);
// Example output:
// {
// conditionalCreate: true,
// conditionalGet: true,
// hybridTransport: true,
// userVerifyingPlatformAuthenticator: true
// }
if (capabilities.userVerifyingPlatformAuthenticator) {
// Device has built-in biometric authentication
enablePasskeyRegistration();
}
});
}`
WebAuthn Registration Flow: Creating Passkeys
Passkey registration follows a precise protocol
involving client-server coordination. The server
generates a cryptographic challenge, the client
(browser/device) creates a public-private key pair, and
the public key is sent to the server for storage.
Server-Side: Generate Registration Options
Your backend must generate registration options
including a random challenge, user details, and relying
party information. Here's a Node.js example using the
`@simplewebauthn/server` library:
`import { generateRegistrationOptions } from '@simplewebauthn/server';
// Backend endpoint: /auth/register/options
app.post('/auth/register/options', async (req, res) => {
const { userId, email, username } = req.body;
const options = await generateRegistrationOptions({
rpName: 'MyApp',
rpID: 'myapp.com', // Your domain
userID: userId,
userName: email,
userDisplayName: username,
// Challenge validity: 5 minutes
timeout: 300000,
// Require platform authenticator (device biometric)
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'required',
residentKey: 'required' // Discoverable credential
},
// Support ES256 and RS256 algorithms
supportedAlgorithmIDs: [-7, -257],
});
// Store challenge in session for verification
req.session.challenge = options.challenge;
res.json(options);
});`
Client-Side: Create Credential
The browser's
`navigator.credentials.create()` API triggers
the platform authenticator (Touch ID, Face ID, Windows
Hello) to create a passkey:
`// Frontend: Register passkey
async function registerPasskey(email, username) {
try {
// 1. Get registration options from server
const optionsRes = await fetch('/auth/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: crypto.randomUUID(),
email,
username
})
});
const options = await optionsRes.json();
// 2. Trigger platform authenticator
const credential = await navigator.credentials.create({
publicKey: {
...options,
challenge: Uint8Array.from(
atob(options.challenge), c => c.charCodeAt(0)
),
user: {
...options.user,
id: Uint8Array.from(
atob(options.user.id), c => c.charCodeAt(0)
)
}
}
});
// 3. Send public key to server
const verificationRes = await fetch('/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credential: {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))),
response: {
attestationObject: btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject))),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON)))
},
type: credential.type
}
})
});
const result = await verificationRes.json();
if (result.verified) {
console.log('Passkey registered successfully!');
return true;
}
} catch (error) {
console.error('Passkey registration failed:', error);
// Handle errors: user cancelled, no authenticator, etc.
return false;
}
}`
Server-Side: Verify and Store Public Key
The backend logic must rigorously verify the client's
response by checking the signature against the stored
public key, the challenge, and the origin (RP ID):
`import { verifyRegistrationResponse } from '@simplewebauthn/server';
app.post('/auth/register/verify', async (req, res) => {
const { credential } = req.body;
const expectedChallenge = req.session.challenge;
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge,
expectedOrigin: 'https://myapp.com',
expectedRPID: 'myapp.com',
});
if (verification.verified) {
// Store public key and credential ID in database
await db.savePasskey({
userId: req.session.userId,
credentialId: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
transports: credential.response.transports
});
res.json({ verified: true });
} else {
res.status(400).json({ error: 'Verification failed' });
}
});`
This verification process ensures the passkey was
created on an authentic device and associates it with
the correct user account. Similar security-critical
verification flows power systems like
[behavioral CAPTCHA authentication.
WebAuthn Authentication Flow: Using Passkeys
Authentication follows a similar challenge-response
pattern but verifies the user possesses the private key
corresponding to their registered public key.
Server-Side: Generate Authentication Challenge
`import { generateAuthenticationOptions } from '@simplewebauthn/server';
app.post('/auth/login/options', async (req, res) => {
const { email } = req.body;
// Retrieve user's registered passkeys
const passkeys = await db.getPasskeysByEmail(email);
const options = await generateAuthenticationOptions({
rpID: 'myapp.com',
userVerification: 'required',
allowCredentials: passkeys.map(pk => ({
id: pk.credentialId,
type: 'public-key',
transports: pk.transports
}))
});
req.session.challenge = options.challenge;
res.json(options);
});`
Client-Side: Authenticate with Passkey
async function authenticateWithPasskey(email) {
try {
// 1. Get authentication options
const optionsRes = await fetch('/auth/login/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const options = await optionsRes.json();
// 2. Trigger device authentication
const assertion = await navigator.credentials.get({
publicKey: {
...options,
challenge: Uint8Array.from(
atob(options.challenge), c => c.charCodeAt(0)
)
}
});
// 3. Send signed assertion to server
const verifyRes = await fetch('/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
assertion: {
id: assertion.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(assertion.rawId))),
response: {
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(assertion.response.authenticatorData))),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(assertion.response.clientDataJSON))),
signature: btoa(String.fromCharCode(...new Uint8Array(assertion.response.signature))),
userHandle: assertion.response.userHandle ? btoa(String.fromCharCode(...new Uint8Array(assertion.response.userHandle))) : null
},
type: assertion.type
}
})
});
const result = await verifyRes.json();
if (result.verified) {
console.log('Authentication successful!');
window.location.href = '/dashboard';
}
} catch (error) {
console.error('Authentication failed:', error);
}
}
Server-Side: Verify Authentication Signature
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
app.post('/auth/login/verify', async (req, res) => {
const { assertion } = req.body;
const expectedChallenge = req.session.challenge;
// Retrieve stored passkey
const passkey = await db.getPasskeyByCredentialId(assertion.id);
const verification = await verifyAuthenticationResponse({
response: assertion,
expectedChallenge,
expectedOrigin: 'https://myapp.com',
expectedRPID: 'myapp.com',
authenticator: {
credentialID: passkey.credentialId,
credentialPublicKey: passkey.publicKey,
counter: passkey.counter
}
});
if (verification.verified) {
// Update counter to prevent replay attacks
await db.updatePasskeyCounter(
assertion.id,
verification.authenticationInfo.newCounter
);
// Create session
req.session.userId = passkey.userId;
res.json({ verified: true });
} else {
res.status(401).json({ error: 'Authentication failed' });
}
});
Advanced Implementation: Conditional UI and Autofill
Chrome 108+ and Safari 16+ support "conditional
UI"—passkeys appear as autofill suggestions in
username/email fields. This provides seamless UX where
users tap their email field and see their passkey as an
option alongside saved passwords.
`// Enable passkey autofill
async function setupPasskeyAutofill() {
if (window.PublicKeyCredential &&
PublicKeyCredential.isConditionalMediationAvailable) {
const available = await PublicKeyCredential.isConditionalMediationAvailable();
if (available) {
// Trigger conditional mediation
const assertion = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(32), // Placeholder
rpId: 'myapp.com',
userVerification: 'required'
},
mediation: 'conditional' // Key parameter
});
// Process assertion when user selects passkey
if (assertion) {
authenticateWithAssertion(assertion);
}
}
}
}
// Add autocomplete attribute to enable autofill UI
// HTML: <input type="email" autocomplete="username webauthn" />`
This pattern works seamlessly with
reward platform authentication
systems where users expect quick, frictionless login
experiences.
Security Best Practices for Production
Implementing WebAuthn securely requires attention to
several critical details:
1. Validate Relying Party ID (RP ID)
The RP ID must match your domain. For
`app.example.com`, valid RP IDs are
`app.example.com` or
`example.com` (parent domain), but NOT
`different.com`. This prevents credential
theft via phishing sites—passkeys created for
`bank.com` will never work on
`bank-login.scam.com`.
2. Enforce User Verification
Always set `userVerification: 'required'` to
ensure biometric or PIN confirmation. This prevents
unauthorized access if someone steals a user's unlocked
device. The authentication satisfies "something you
have" (device) AND "something you are" (biometric)
factors.
3. Implement Counter Validation
Authenticators return a signature counter that
increments with each use. If you receive a counter value
lower than the stored value, it indicates a cloned
authenticator—a potential security breach. Always verify
and update counters:
`if (receivedCounter > 0 && receivedCounter
Originally published at blog.magicauth.app
Top comments (0)