Passkeys (WebAuthn)
Phishing-resistant, passwordless sign-in with FIDO2 passkeys: registration, usernameless login and self-service management.
A passkey is a FIDO2 / WebAuthn credential: a key pair created by the user's device (Touch ID, Windows Hello, a security key, a phone). The private key never leaves the authenticator; Obexal stores only the public key. Passkeys resist phishing by design, because the browser cryptographically binds each assertion to the sign-in domain.
How passkeys work in Obexal
Every WebAuthn operation is a two-step ceremony:
- begin: the server returns WebAuthn options (
publicKey) plus an opaquestateToken. The ceremony state is kept server-side for 5 minutes, indexed by the hash of the token. - finish: the browser produces a
PublicKeyCredential, which you send back with thestateToken. The token is single use: a replayed or expiredfinishfails generically.
Signature, challenge, origin and attestation checks are delegated to the audited go-webauthn library; nothing cryptographic is reimplemented.
Prerequisites
- HTTPS: browsers only expose the WebAuthn API in a secure context (
https://, orlocalhostin development). - A modern browser: all current Chrome, Edge, Firefox and Safari releases support WebAuthn.
- All
POST /v1/...calls require the CSRF double-submit token (fetch it fromGET /v1/csrf).
A passkey is bound to the sign-in domain (the WebAuthn Relying Party ID). Examples below use accounts.obexal.com; if your organization signs in on a custom domain, replace it accordingly.
Register a passkey
Registration requires an authenticated session (the user adds a passkey to an existing account).
# 1. Begin: get the creation options (session cookie + CSRF).
curl -sS -X POST https://accounts.obexal.com/v1/webauthn/register/begin \
-b cookies.txt -H "X-CSRF-Token: $CSRF"
# 200 -> {"publicKey":{"authenticatorSelection":{"residentKey":"required",
# "userVerification":"preferred"},"excludeCredentials":[...],...},
# "stateToken":"Q2VyZW1vbnk..."}The options require a resident key (discoverable credential) and ask for userVerification: preferred. excludeCredentials lists the passkeys already registered, so the browser refuses duplicates. Pass the publicKey object to navigator.credentials.create(), then finish:
# 2. Finish: send back the browser's PublicKeyCredential with the stateToken.
curl -sS -X POST https://accounts.obexal.com/v1/webauthn/register/finish \
-b cookies.txt -H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \
-d '{"stateToken":"Q2VyZW1vbnk...","name":"MacBook Touch ID","credential":{...}}'
# 201 -> {"credential":{"id":"0f1e2d...","name":"MacBook Touch ID","createdAt":"..."}}name is an optional label (64 characters maximum) shown in the passkey list.
Sign in with a passkey
Sign-in is usernameless: no email is asked. The discoverable credential carries the user identity (userHandle), and the server maps it back to the account.
# 1. Begin: no authentication, empty body. Rate limited per IP.
curl -sS -X POST https://accounts.obexal.com/v1/webauthn/login/begin \
-H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' -d '{}'
# 200 -> {"publicKey":{"challenge":"...","userVerification":"preferred",
# "allowCredentials":[]},"stateToken":"..."}Pass publicKey to navigator.credentials.get() (conditional mediation gives the browser's autofill experience), then:
# 2. Finish: on success the session cookie is set.
curl -sS -X POST https://accounts.obexal.com/v1/webauthn/login/finish \
-H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \
-d '{"stateToken":"...","credential":{...}}'
# 200 -> {"user":{"id":"...","email":"a@b.eu","status":"active",...}}Failures (invalid assertion, unknown or consumed stateToken, unknown credential) return a generic 401 invalid_credentials.
A strong primary factor
A passkey sign-in opens the session directly: no additional MFA challenge is stacked on top. This is intentional. A passkey already combines possession of the device with user verification, and it is phishing resistant, so it is at least as strong as a password plus a second factor. Password, passwordless email and social sign-ins, by contrast, all go through the MFA chokepoint.
One honest nuance: Obexal requests userVerification: preferred, not required. The authenticator is asked to verify the user (biometrics or PIN) whenever it can, but an assertion made without user verification is not rejected.
Manage your passkeys
Users manage their own passkeys (session required):
curl -sS -b cookies.txt https://accounts.obexal.com/v1/webauthn/credentials{ "credentials": [ { "id": "0f1e2d...", "name": "MacBook Touch ID", "createdAt": "2026-06-14T10:00:00Z", "lastUsedAt": "2026-06-14T12:30:00Z" } ] }lastUsedAt stays null until the passkey is first used to sign in. To remove one:
curl -sS -b cookies.txt -X POST https://accounts.obexal.com/v1/webauthn/credentials/delete \
-H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \
-d '{"credentialId":"0f1e2d..."}'
# 204Deletion is scoped to the session's user: nobody can delete another account's passkey through this endpoint.
Security notes
- Clone detection: on every sign-in the authenticator's signature counter is checked. A counter regression (a possible cloned authenticator) is recorded in the audit log. It does not block the sign-in, because synced passkeys legitimately report a counter of zero, but it gives you a forensic trail.
- Single-use ceremonies: only the hash of each
stateTokenis stored, with a 5 minute TTL, and it is consumed atomically onfinish. - Rate limiting:
login/beginandlogin/finishare rate limited per IP. - Registration, sign-in, clone warnings and deletions are all written to the audit log.