WebAuthn / Passkeys
SyAuth supports WebAuthn — the W3C standard behind passkeys, Touch ID, Face ID, Windows Hello, and hardware security keys (YubiKey, SoloKeys, Google Titan…).
Passkeys are:
- Phishing-resistant by design. The browser binds every assertion to the exact origin, so a lookalike domain can't collect one.
- Faster than a 6-digit code. One biometric prompt or tap — no app switch, no typing.
- Stored where the user already trusts them. iCloud Keychain, Google Password Manager, 1Password, Bitwarden, a hardware key — we don't replace the user's credential manager, we integrate with it.
Credentials are stored in the same UserMFADevice table as TOTP, under device_type='webauthn'. The meta JSONB column holds the credential id, public key, sign counter, and transports — no schema changes required to onboard passkeys.
End-user flow
Enrollment
- User visits Two-factor Auth in the SyAuth dashboard (
/security/mfa). - Under Passkey / security key, names the device and clicks Register a passkey.
- The browser prompts the user's platform authenticator (Touch ID, Windows Hello, or asks them to insert a security key).
- SyAuth verifies the attestation, stores the credential, and marks it confirmed.
From this point, every login on this account is gated on either a TOTP code or a passkey assertion — whichever the user prefers.
Sign-in
After primary authentication (password or magic-link), if the account has MFA enrolled, the response is:
{
"mfa_required": true,
"mfa_ticket": "…",
"mfa_methods": ["totp", "webauthn"]
}
If "webauthn" is in mfa_methods and the browser supports it, the hosted login shows a Sign in with a passkey button. Clicking it:
- Calls
/auth/webauthn/authenticate/begin/with the ticket → receives a challenge. - Calls
navigator.credentials.get()to produce an assertion. - Calls
/auth/webauthn/authenticate/finish/with the ticket + assertion. - On success, receives the normal
{ user, token, refresh_token }payload.
If the user prefers a TOTP code instead, they can fall back to the code input on the same screen.
Configuration
SyAuth derives its Relying Party config from environment variables. Defaults are sensible in single-domain deployments; override for multi-origin setups.
| Setting | Default | Meaning |
|---|---|---|
WEBAUTHN_RP_ID | Falls back to APP_DOMAIN | Registrable domain (no scheme, no port). Passkeys created under this RP ID will work on any subdomain of it. |
WEBAUTHN_RP_NAME | "SyAuth" | Human-readable name shown to the user by some authenticators. |
WEBAUTHN_ORIGIN | https://${WEBAUTHN_RP_ID} | Single string or comma-separated list of allowed origins. Must match the URL the browser is on exactly, including scheme and port. |
Example for a two-surface deployment:
WEBAUTHN_RP_ID=syauth.com
WEBAUTHN_RP_NAME=SyAuth
WEBAUTHN_ORIGIN=https://app.syauth.com,https://console.syauth.com
Changing WEBAUTHN_RP_ID after credentials exist invalidates every registered passkey. Pick the lowest reasonable registrable suffix (e.g. syauth.com, not app.syauth.com) when you first enable WebAuthn.
API reference
All binary fields in request/response payloads are base64url-encoded (per the WebAuthn JSON spec).
Enrollment
Both endpoints require Authorization: Bearer <access_token>.
POST /user/mfa/webauthn/register/begin/
Body (optional):
{ "name": "YubiKey 5C" }
Response (raw PublicKeyCredentialCreationOptions JSON — feed it straight to navigator.credentials.create() after decoding binary fields):
{
"rp": { "id": "syauth.com", "name": "SyAuth" },
"user": { "id": "…b64url…", "name": "[email protected]", "displayName": "Alice" },
"challenge": "…b64url…",
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
"excludeCredentials": [ … already-registered credentials … ],
"authenticatorSelection": {
"residentKey": "preferred",
"userVerification": "preferred"
}
}
Calling again before completing burns the previous challenge and re-issues one. Pending, unconfirmed devices are reused.
POST /user/mfa/webauthn/register/finish/
Body:
{
"credential": { … WebAuthn RegistrationCredential JSON … }
}
Response:
{ "device_id": "…", "name": "YubiKey 5C", "confirmed": true }
The credential is persisted with transports, device type, and backup flags so later decisions (e.g. "only allow single-device passkeys") can be made against stored metadata.
Sign-in
Both endpoints are public and require the mfa_ticket returned by LoginView or MagicLinkConsumeView.
POST /auth/webauthn/authenticate/begin/
Body:
{ "mfa_ticket": "…" }
Response is a raw PublicKeyCredentialRequestOptions JSON. The server stores the challenge keyed against the ticket, so only the ticket-holder can complete the flow.
The ticket is not consumed by this call; it remains valid until finish.
POST /auth/webauthn/authenticate/finish/
Body:
{
"mfa_ticket": "…",
"credential": { … WebAuthn AuthenticationCredential JSON … }
}
On success:
{
"user": { … },
"token": "eyJ…",
"refresh_token": "eyJ…",
"message": "Login successful"
}
On failure: 401 Unauthorized with an error field. The ticket is consumed on any attempt (single-use, same as TOTP), so retries require restarting the login.
Browser client example
import { httpClient } from '@/utils/httpClient'
const b64urlToBytes = (v: string): Uint8Array => {
const b64 = (v + '==='.slice((v.length + 3) % 4)).replace(/-/g, '+').replace(/_/g, '/')
return Uint8Array.from(atob(b64), c => c.charCodeAt(0))
}
const bytesToB64url = (buf: ArrayBuffer): string =>
btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
export async function signInWithPasskey(mfaTicket: string) {
const options = await httpClient.post('/api/auth/webauthn/authenticate/begin/', {
mfa_ticket: mfaTicket,
})
const cred = await navigator.credentials.get({
publicKey: {
...options,
challenge: b64urlToBytes(options.challenge),
allowCredentials: options.allowCredentials?.map((c: any) => ({
...c,
id: b64urlToBytes(c.id),
})),
},
}) as PublicKeyCredential
return httpClient.post('/api/auth/webauthn/authenticate/finish/', {
mfa_ticket: mfaTicket,
credential: {
id: cred.id,
rawId: bytesToB64url(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bytesToB64url((cred.response as any).clientDataJSON),
authenticatorData: bytesToB64url((cred.response as any).authenticatorData),
signature: bytesToB64url((cred.response as any).signature),
userHandle: (cred.response as any).userHandle
? bytesToB64url((cred.response as any).userHandle) : null,
},
},
})
}
The SyAuth SDK ships this wrapper as webauthnService.verifyLogin(ticket) and the enrollment equivalent as webauthnService.register(name).
Audit events
Every enrollment and assertion emits an immutable entry to the Audit Log:
| Event | Severity | When |
|---|---|---|
webauthn_register_start | info | Registration options issued |
webauthn_register_success | warn | Credential persisted — MFA now active |
webauthn_register_failure | warn | Attestation rejected |
webauthn_authenticate_success | info | Assertion accepted during login |
webauthn_authenticate_failure | warn | Assertion rejected — bad challenge, unknown credential, or signature mismatch |
Security notes
- Sign counter rotated on every successful assertion. If a cloned authenticator is ever used, its counter will fall behind and verification will reject the next assertion. Stored in
meta.sign_count. exclude_credentialson registration. Prevents the same authenticator enrolling twice on a single account.user_verification: preferred. We accept UV-less assertions (useful for hardware keys without a PIN) but prefer verified ones when the authenticator can do it. Flip torequiredif your compliance posture demands it.- Single-use login tickets. The MFA ticket issued by
LoginViewis burned by the finish endpoint; a leaked ticket cannot be replayed to generate multiple assertions. - Challenges are cached in Redis with a 5-minute TTL, keyed by user id (enrollment) or ticket (sign-in).
- No attestation collected by default. We pass the W3C-default
attestation=noneto maximise compatibility with consumer authenticators. Change todirectif your policy requires tracking AAGUIDs.
Policy recommendations
- Encourage passkeys over TOTP. They're more resistant to phishing, don't require typing, and don't rely on the user's clock being correct.
- Pair with account recovery. A user who loses every passkey AND their TOTP device is locked out. Ship recovery codes (constants are reserved as
UserMFADevice.device_type='recovery') or an email-based recovery with manual review before the lockout window closes. - Show device metadata in the dashboard.
meta.credential_device_typeandmeta.credential_backed_uptell you whether the passkey is synced (iCloud/Google) or single-device. Some compliance regimes care about this distinction.