Passkey Authentication (WebAuthn)
FIDO2/WebAuthn-based passwordless authentication using biometrics or hardware security keys.
Overview
Passkeys provide phishing-resistant passwordless authentication:
- Touch ID / Face ID on Apple devices
- Windows Hello on Windows
- Hardware security keys (YubiKey, etc.)
- Android fingerprint/face unlock
Endpoints
| Method | Path | Description |
|---|---|---|
POST |
/auth/passkeys/register/start |
Start registration |
POST |
/auth/passkeys/register/finish |
Complete registration |
POST |
/auth/passkeys/authenticate/start |
Start authentication |
POST |
/auth/passkeys/authenticate/finish |
Complete authentication |
Registration
POST /auth/passkeys/register/start
Start passkey registration ceremony.
Synopsis
| Property | Value |
|---|---|
| Authentication | Required (JWT) |
| Challenge Lifetime | 5 minutes |
Request Headers
| Header | Value | Required |
|---|---|---|
Authorization |
Bearer {jwt} |
Yes |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
No | Passkey name (e.g., “My YubiKey”) |
Response (200 OK)
{
"challenge_id": "challenge-uuid-123",
"options": {
"challenge": "Y2hhbGxlbmdlLWJhc2U2NC1lbmNvZGVk",
"rp": {
"name": "AuthOS",
"id": "sso.example.com"
},
"user": {
"id": "user-id-456",
"name": "user@example.com",
"displayName": "user@example.com"
},
"pubKeyCredParams": [
{"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -257}
],
"timeout": 60000,
"authenticatorSelection": {
"authenticatorAttachment": "cross-platform",
"requireResidentKey": false,
"userVerification": "preferred"
}
}
}
Pass options to navigator.credentials.create() in the browser.
POST /auth/passkeys/register/finish
Complete passkey registration.
Synopsis
| Property | Value |
|---|---|
| Authentication | Required (JWT) |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
challenge_id |
string |
Yes | Challenge ID from start |
credential |
object |
Yes | WebAuthn PublicKeyCredential |
Response (200 OK)
{
"success": true,
"passkey_id": "passkey-id-789"
}
Errors
| Status | Condition |
|---|---|
400 |
Invalid or expired challenge |
401 |
Challenge doesn’t belong to user |
500 |
WebAuthn verification failed |
Authentication
POST /auth/passkeys/authenticate/start
Start passkey authentication.
Synopsis
| Property | Value |
|---|---|
| Authentication | Public |
| Challenge Lifetime | 5 minutes |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email |
string |
Yes | User’s email address |
Response (200 OK)
{
"challenge_id": "challenge-uuid-456",
"options": {
"challenge": "Y2hhbGxlbmdlLWJhc2U2NC1lbmNvZGVk",
"timeout": 60000,
"rpId": "sso.example.com",
"allowCredentials": [
{"type": "public-key", "id": "base64-credential-id-1"},
{"type": "public-key", "id": "base64-credential-id-2"}
],
"userVerification": "preferred"
}
}
Pass options to navigator.credentials.get() in the browser.
Errors
| Status | Condition |
|---|---|
404 |
User not found |
400 |
No passkeys registered |
POST /auth/passkeys/authenticate/finish
Complete passkey authentication and get JWT.
Synopsis
| Property | Value |
|---|---|
| Authentication | Public |
| Risk Assessment | Yes |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
challenge_id |
string |
Yes | Challenge ID from start |
credential |
object |
Yes | WebAuthn PublicKeyCredential |
Response (200 OK)
{
"token": "eyJhbGciOiJSUzI1NiIs...",
"user_id": "user-id-456",
"device_trust_token": "device-trust-token-xyz"
}
Errors
| Status | Condition |
|---|---|
400 |
Invalid or expired challenge |
403 |
Blocked by risk engine |
403 |
MFA required (fallback to another method) |
Client Implementation
Register Passkey
async function registerPasskey() {
// 1. Start registration
const startResponse = await fetch('/auth/passkeys/register/start', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: 'My Security Key' })
});
const { challenge_id, options } = await startResponse.json();
// 2. Create credential with browser API
const credential = await navigator.credentials.create({
publicKey: {
...options,
challenge: base64ToArrayBuffer(options.challenge),
user: {
...options.user,
id: base64ToArrayBuffer(options.user.id)
}
}
});
// 3. Finish registration
await fetch('/auth/passkeys/register/finish', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
challenge_id,
credential: serializeCredential(credential)
})
});
}
Authenticate with Passkey
async function authenticateWithPasskey(email) {
// 1. Start authentication
const startResponse = await fetch('/auth/passkeys/authenticate/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const { challenge_id, options } = await startResponse.json();
// 2. Get credential with browser API
const credential = await navigator.credentials.get({
publicKey: {
...options,
challenge: base64ToArrayBuffer(options.challenge),
allowCredentials: options.allowCredentials.map(c => ({
...c,
id: base64ToArrayBuffer(c.id)
}))
}
});
// 3. Finish authentication
const finishResponse = await fetch('/auth/passkeys/authenticate/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge_id,
credential: serializeCredential(credential)
})
});
return await finishResponse.json();
}
Security Features
| Feature | Description |
|---|---|
| Phishing Resistant | Credentials bound to origin |
| Clone Detection | Counter validation prevents copying |
| Risk Assessment | Evaluates login context |
| Device Trust | Establishes trusted device cookie |