WebAuthn / Passkeys
Tetrapus supports W3C WebAuthn for both first-factor passkey login and second-factor MFA. Roaming authenticators (YubiKey, Solo, Titan), platform authenticators (Touch ID, Windows Hello, Android), and synced passkeys (iCloud Keychain, Google Password Manager) all work. No SMS, no TOTP fallback required — modern hardware only.
Two-step ceremonies
Both enrolment and authentication are two-step: the server issues a challenge in start, the
browser performs the cryptographic ceremony with the authenticator, and the resulting attestation or
assertion is POSTed back to finish.
Enrolment flow
Authentication flow
REST routes
| Method | Path | Step |
|---|---|---|
| POST | /api/v1/auth/mfa/webauthn/register/start | Issue enrolment challenge |
| POST | /api/v1/auth/mfa/webauthn/register/finish | Verify + persist credential |
| POST | /api/v1/auth/mfa/webauthn/auth/start | Issue auth challenge |
| POST | /api/v1/auth/mfa/webauthn/auth/finish | Verify assertion → mint session |
Browser-side handoff
The server returns options pre-decoded for direct passing into the WebAuthn JS API. A typical RP page:
// 1) Ask server for the challenge
const startRes = await fetch('/api/v1/auth/mfa/webauthn/register/start', {
method: 'POST',
credentials: 'include',
});
const { publicKey } = await startRes.json();
// 2) Hand to the browser. User taps key.
const cred = await navigator.credentials.create({ publicKey });
// 3) Send the attestation back to finish
await fetch('/api/v1/auth/mfa/webauthn/register/finish', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: cred.id,
rawId: base64url(cred.rawId),
type: cred.type,
response: {
attestationObject: base64url(cred.response.attestationObject),
clientDataJSON: base64url(cred.response.clientDataJSON),
},
}),
}); Persistence — webauthn_credentials
CREATE TABLE webauthn_credentials (
credential_id BLOB PRIMARY KEY, -- raw credential ID from the authenticator
user_id TEXT NOT NULL REFERENCES users(id),
public_key_cbor BLOB NOT NULL, -- COSE_Key in CBOR
sign_count INTEGER NOT NULL DEFAULT 0, -- monotonic counter, replay defense
transports TEXT, -- JSON: ["usb","nfc","internal"]
aaguid BLOB, -- authenticator model GUID
label TEXT NOT NULL, -- "YubiKey 5C NFC", user-editable
created_at TEXT NOT NULL,
last_used_at TEXT
); Replay defence
The authenticator increments signCount
on every assertion. Tetrapus stores the last seen value and rejects any assertion whose counter is
less-than-or-equal to it. A counter regression is logged as a forensic event — it usually means a
cloned authenticator and warrants killing the credential.
Status
The full WebAuthnService lives behind the webauthn Cargo feature flag.
Builds without that feature ship the route stubs that return 501 Not Implemented — useful for
embedded edge deployments that don't need passkeys and want to drop the dependency footprint.
Enrol credential management UI (rename, delete, list) is shipped under
Profile → Security → Passkeys.
Related
- Smart Cards (PIV/CAC) — for DoD/USG hardware tokens
- SAML 2.0 — WebAuthn pairs naturally as a local MFA on top of SAML
- ← Federation overview
Questions?
Reach out for help with integration, deployment, or custom domain codecs.