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
| Type | Examples |
|---|---|
| Platform (built-in) | Apple Touch ID / Face ID, Windows Hello, Android biometrics |
| Cross-platform (roaming) | YubiKey, Google Titan Key, SoloKey |
| Hybrid | Phone as authenticator (QR code pairing) |
Supported algorithms
| COSE alg | Standard | Notes |
|---|---|---|
-7 | ES256 (ECDSA P-256 + SHA-256) | Default — supported by all modern authenticators |
-257 | RS256 (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_URLvalue (e.g.https://auth.example.com)
Changing
EXTERNAL_URLinvalidates 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
400error — 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
| Error | Cause | Fix |
|---|---|---|
rpIdHash mismatch | Browser origin ≠ server EXTERNAL_URL | Ensure EXTERNAL_URL matches the URL users access |
Challenge mismatch | Wrong challenge sent or clock skew | Use the challenge_id from the begin response |
Challenge has expired | More than 5 minutes elapsed | Restart the flow |
Sign counter regression | Possible cloned authenticator | Investigate; user may need to re-register |
No WebAuthn credentials registered | User has no passkeys | Direct user to /register/begin first |