Aller au contenu principal

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

  1. User visits Two-factor Auth in the SyAuth dashboard (/security/mfa).
  2. Under Passkey / security key, names the device and clicks Register a passkey.
  3. The browser prompts the user's platform authenticator (Touch ID, Windows Hello, or asks them to insert a security key).
  4. 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:

  1. Calls /auth/webauthn/authenticate/begin/ with the ticket → receives a challenge.
  2. Calls navigator.credentials.get() to produce an assertion.
  3. Calls /auth/webauthn/authenticate/finish/ with the ticket + assertion.
  4. 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.

SettingDefaultMeaning
WEBAUTHN_RP_IDFalls back to APP_DOMAINRegistrable 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_ORIGINhttps://${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
RP ID is sticky

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:

EventSeverityWhen
webauthn_register_startinfoRegistration options issued
webauthn_register_successwarnCredential persisted — MFA now active
webauthn_register_failurewarnAttestation rejected
webauthn_authenticate_successinfoAssertion accepted during login
webauthn_authenticate_failurewarnAssertion 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_credentials on 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 to required if your compliance posture demands it.
  • Single-use login tickets. The MFA ticket issued by LoginView is 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=none to maximise compatibility with consumer authenticators. Change to direct if 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_type and meta.credential_backed_up tell you whether the passkey is synced (iCloud/Google) or single-device. Some compliance regimes care about this distinction.