Passkey Authentication

WebAuthn/FIDO2 passwordless authentication with biometrics and security keys

Updated Dec 29, 2025 Edit this page

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