Obexal Docs

Docs/Authentication/Passkeys (WebAuthn)

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:

  1. begin: the server returns WebAuthn options (publicKey) plus an opaque stateToken. The ceremony state is kept server-side for 5 minutes, indexed by the hash of the token.
  2. finish: the browser produces a PublicKeyCredential, which you send back with the stateToken. The token is single use: a replayed or expired finish fails 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://, or localhost in 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 from GET /v1/csrf).
Note

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..."}'
# 204

Deletion 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 stateToken is stored, with a 5 minute TTL, and it is consumed atomically on finish.
  • Rate limiting: login/begin and login/finish are rate limited per IP.
  • Registration, sign-in, clone warnings and deletions are all written to the audit log.