vaultaris /docs

WebAuthn / Passkeys / FIDO2

Complete guide to integrating WebAuthn passwordless authentication and passkeys with Vaultaris.

Vaultaris provides a complete, spec-compliant W3C WebAuthn Level 2 implementation with no external WebAuthn library dependency. Users can register hardware security keys (YubiKey, etc.), platform authenticators (Touch ID, Face ID, Windows Hello), or any FIDO2-compatible device as a second factor or as a passwordless credential.

Supported authenticators

TypeExamples
Platform (built-in)Apple Touch ID / Face ID, Windows Hello, Android biometrics
Cross-platform (roaming)YubiKey, Google Titan Key, SoloKey
HybridPhone as authenticator (QR code pairing)

Supported algorithms

COSE algStandardNotes
-7ES256 (ECDSA P-256 + SHA-256)Default — supported by all modern authenticators
-257RS256 (RSA-PKCS1v15 + SHA-256)Legacy support

API flow

Registration (2 steps)

Step 1 — Begin registration

POST /api/v1/mfa/webauthn/register/begin
Authorization: Bearer {access_token}

Response:

{
  "challenge_id": "550e8400-e29b-41d4-a716-446655440000",
  "options": {
    "challenge": "<base64url>",
    "rp": { "id": "example.com", "name": "Vaultaris" },
    "user": { "id": "<base64url>", "name": "alice", "displayName": "Alice" },
    "pubKeyCredParams": [
      { "type": "public-key", "alg": -7 },
      { "type": "public-key", "alg": -257 }
    ],
    "timeout": 60000,
    "attestation": "none",
    "authenticatorSelection": {
      "residentKey": "preferred",
      "userVerification": "preferred"
    },
    "excludeCredentials": []
  }
}

Pass options to the browser's navigator.credentials.create() API.

Step 2 — Complete registration

After the user interacts with their authenticator, send the result:

POST /api/v1/mfa/webauthn/register/complete
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "challenge_id": "550e8400-e29b-41d4-a716-446655440000",
  "device_name": "MacBook Touch ID",
  "id": "<base64url credential ID from browser>",
  "response": {
    "clientDataJSON": "<base64url>",
    "attestationObject": "<base64url>",
    "transports": ["internal"]
  }
}

The server verifies the attestation, extracts the public key, and stores the credential. Returns the stored WebAuthnCredential object on success.

Authentication (2 steps)

Step 1 — Begin authentication

POST /api/v1/mfa/webauthn/authenticate/begin
Authorization: Bearer {access_token}

Response:

{
  "challenge_id": "7f8a9b00-1234-5678-abcd-ef0123456789",
  "options": {
    "challenge": "<base64url>",
    "timeout": 60000,
    "rpId": "example.com",
    "allowCredentials": [
      { "type": "public-key", "id": "<base64url>", "transports": ["internal"] }
    ],
    "userVerification": "preferred"
  }
}

Pass options to the browser's navigator.credentials.get() API.

Step 2 — Complete authentication

POST /api/v1/mfa/webauthn/authenticate/complete
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "challenge_id": "7f8a9b00-1234-5678-abcd-ef0123456789",
  "id": "<base64url credential ID>",
  "response": {
    "clientDataJSON": "<base64url>",
    "authenticatorData": "<base64url>",
    "signature": "<base64url>",
    "userHandle": "<base64url or null>"
  }
}

The server verifies the assertion signature, updates the sign counter, and returns the verified WebAuthnCredential.

Credential management

# List all registered credentials
GET /api/v1/mfa/webauthn/credentials
Authorization: Bearer {access_token}

# Remove a credential
DELETE /api/v1/mfa/webauthn/credentials/{credential_id}
Authorization: Bearer {access_token}

Browser integration example

// ---- Registration ----

async function registerPasskey(accessToken) {
  // Step 1: get challenge from server
  const beginResp = await fetch('/api/v1/mfa/webauthn/register/begin', {
    method: 'POST',
    headers: { Authorization: `Bearer ${accessToken}` }
  });
  const { challenge_id, options } = await beginResp.json();

  // Decode base64url values for the browser API
  options.challenge = base64urlDecode(options.challenge);
  options.user.id = base64urlDecode(options.user.id);
  if (options.excludeCredentials) {
    options.excludeCredentials = options.excludeCredentials.map(c => ({
      ...c, id: base64urlDecode(c.id)
    }));
  }

  // Step 2: browser prompts user
  const credential = await navigator.credentials.create({ publicKey: options });

  // Step 3: send result to server
  const completeResp = await fetch('/api/v1/mfa/webauthn/register/complete', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${accessToken}`
    },
    body: JSON.stringify({
      challenge_id,
      device_name: 'My device',
      id: credential.id,
      response: {
        clientDataJSON: base64urlEncode(credential.response.clientDataJSON),
        attestationObject: base64urlEncode(credential.response.attestationObject),
        transports: credential.response.getTransports?.() ?? []
      }
    })
  });
  return completeResp.json();
}

// ---- Authentication ----

async function authenticateWithPasskey(accessToken) {
  // Step 1: get challenge
  const beginResp = await fetch('/api/v1/mfa/webauthn/authenticate/begin', {
    method: 'POST',
    headers: { Authorization: `Bearer ${accessToken}` }
  });
  const { challenge_id, options } = await beginResp.json();

  options.challenge = base64urlDecode(options.challenge);
  if (options.allowCredentials) {
    options.allowCredentials = options.allowCredentials.map(c => ({
      ...c, id: base64urlDecode(c.id)
    }));
  }

  // Step 2: browser prompts user
  const assertion = await navigator.credentials.get({ publicKey: options });

  // Step 3: verify on server
  const completeResp = await fetch('/api/v1/mfa/webauthn/authenticate/complete', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${accessToken}`
    },
    body: JSON.stringify({
      challenge_id,
      id: assertion.id,
      response: {
        clientDataJSON: base64urlEncode(assertion.response.clientDataJSON),
        authenticatorData: base64urlEncode(assertion.response.authenticatorData),
        signature: base64urlEncode(assertion.response.signature),
        userHandle: assertion.response.userHandle
          ? base64urlEncode(assertion.response.userHandle)
          : null
      }
    })
  });
  return completeResp.json();
}

Configuration

WebAuthn needs no additional environment variables. The server derives everything from EXTERNAL_URL:

  • rpId — hostname extracted from EXTERNAL_URL (e.g. auth.example.com)
  • expected origin — full EXTERNAL_URL value (e.g. https://auth.example.com)

Changing EXTERNAL_URL invalidates all existing WebAuthn credentials. Notify users before changing it in production.

Security considerations

  • Challenges expire after 5 minutes and are single-use.
  • A sign counter regression (returned counter ≤ stored value) is rejected with a 400 error — this is a strong indicator of a cloned authenticator.
  • All credential IDs are unique per tenant ((tenant_id, credential_id_base64) unique constraint).
  • The attestation format requested is none (does not require attestation certificate chain) but the public key is always extracted and verified from the authenticatorData.

Troubleshooting

ErrorCauseFix
rpIdHash mismatchBrowser origin ≠ server EXTERNAL_URLEnsure EXTERNAL_URL matches the URL users access
Challenge mismatchWrong challenge sent or clock skewUse the challenge_id from the begin response
Challenge has expiredMore than 5 minutes elapsedRestart the flow
Sign counter regressionPossible cloned authenticatorInvestigate; user may need to re-register
No WebAuthn credentials registeredUser has no passkeysDirect user to /register/begin first