Passkeys (WebAuthn / FIDO2)

Register, authenticate and manage WebAuthn passkeys for PasskeyBridge accounts: nine actions on one console-only edge function, a fixed relying party, five-minute single-use challenges, and the hosted relying party for your own domain.

Last reviewed September 17, 2026Fresh

Overview

PasskeyBridge signs users into its own dashboard with WebAuthn passkeys. The passkey edge function is the relying party: it mints challenges, verifies attestation and assertion responses with @simplewebauthn/server, stores credential public keys in shield_passkey_credentials, and mints a Supabase session at the end of a sign-in ceremony.

Auth lane: console only. passkey is not in the public function allowlist, so https://api.passkeybridge.io/v1/passkey answers 404 with x-pb-reason: unknown-function. The dashboard and the sign-in page reach it with supabase.functions.invoke("passkey", ...). Every request must also carry an Origin header that appears in the relying-party allowlist; any other origin is refused with 403. There is no API key scope for it and no plan gate.

Nine actions share the single endpoint. Seven resolve the caller from the Authorization: Bearer header carrying the session access token that supabase.functions.invoke attaches. Two run before a session exists.

ActionAuthPurpose
login_optionsnoneChallenge for a discoverable-credential sign-in
login_verifynoneVerify the assertion and return a session bootstrap token
registration_optionssession JWTChallenge for enrolling a new credential
verify_registrationsession JWTVerify the attestation and store the credential
authentication_optionssession JWTChallenge for a step-up check by a signed-in user
verify_authenticationsession JWTVerify a step-up assertion and advance the counter
check_enrolledsession JWTWhether this user has any credential
listsession JWTCredential metadata for this user
deletesession JWTRemove one credential by credential_id

The function holds the Supabase service-role key internally; the browser never sees it. Passkeys for your own users on your own domain are a separate surface, the hosted relying party shield-passkey-rp, described under Relying party configuration.

Registration flow

Enrollment is two round trips: ask for options, run the browser ceremony, send the attestation back.

Step 1: request options.

{ "action": "registration_options" }

The server lists the user's existing credential ids into excludeCredentials so the same authenticator cannot enroll twice, deletes any earlier registration challenge for the user, and stores the new one. The response is the WebAuthn creation options as the library generates them:

{
  "rp": { "name": "PasskeyBridge", "id": "passkeybridge.io" },
  "user": { "id": "<base64url>", "name": "you@example.com", "displayName": "you@example.com" },
  "challenge": "<base64url>",
  "attestation": "none",
  "excludeCredentials": [{ "id": "<base64url>", "type": "public-key" }],
  "authenticatorSelection": { "residentKey": "preferred", "userVerification": "preferred" }
}

user.name and user.displayName are the account email, falling back to the user id when the account has no email. pubKeyCredParams and timeout come from library defaults.

Step 2: browser ceremony. Pass the options to startRegistration({ optionsJSON }) from @simplewebauthn/browser, which calls navigator.credentials.create() and returns an attestation response.

Step 3: verify.

{ "action": "verify_registration", "response": { "id": "...", "rawId": "...", "response": { "...": "..." }, "type": "public-key" } }

The server takes the newest stored registration challenge, rejects it if it is older than five minutes, claims it with a delete that returns the row (so two concurrent verifies cannot both use it), and verifies the response against the challenge, the allowlisted origins and the RP ID. On success:

{ "verified": true, "credential_id": "<base64url>" }

What is stored. The credential row carries user_id, tenant_id, credential_id, public_key (the COSE public key, base64), counter, device_type (singleDevice or multiDevice), backed_up, and transports copied from the attestation response. The tenant is the user's oldest shield_tenant_members row; a user with no membership gets 400 and no credential is stored. The aaguid column exists but this function never writes it, so the authenticator model is not recorded for dashboard passkeys. The private key never leaves the authenticator.

Sign-in and step-up verification

Two different ceremonies use the stored credential: signing in before a session exists, and re-checking a user who is already signed in.

Sign-in (unauthenticated). login_options takes no other field and needs no token:

{ "action": "login_options" }

It returns authentication options with an empty allowCredentials array, which is what makes the ceremony discoverable: the browser offers whichever passkey it holds for passkeybridge.io, with no email typed first. The response carries one extra field:

{ "challenge": "<base64url>", "allowCredentials": [], "userVerification": "preferred", "challenge_handle": "3f1a..." }

challenge_handle names the stored challenge row. Send it back with the assertion:

{ "action": "login_verify", "response": { "id": "...", "response": { "...": "..." }, "type": "public-key" }, "challenge_handle": "3f1a..." }

The server looks the credential up by credential_id across all tenants (the user has not identified themselves yet, so the passkey is the identity), checks the credential's tenant still exists, claims the challenge, verifies the assertion, advances counter and last_used_at, and mints a magic-link token that is never emailed:

{ "verified": true, "token_hash": "<one-time token>" }

The client exchanges it for a session with supabase.auth.verifyOtp({ token_hash, type: "magiclink" }). Treat token_hash as session material: it is enough to sign in as that user once.

Step-up (signed in). authentication_options builds allowCredentials from this user's own credentials, each with its stored transports, and stores an authentication challenge. verify_authentication takes { "action": "verify_authentication", "response": { ... } }, claims the challenge, loads the credential scoped to credential_id and user_id, and answers { "verified": true } or { "verified": false }. A verified assertion updates counter and last_used_at. Nothing in the dashboard calls this pair today; it is available for an application that wants a second check before a sensitive action.

Counter handling. @simplewebauthn/server compares the presented signature counter with the stored one and rejects a counter that has not advanced. That rejection is a thrown exception, and both paths catch it: the sign-in path answers 401, and the step-up path answers 200 with verified: false, which is its ordinary negative answer. Until 2026-09-17 the step-up path did not catch it and answered 500, so a cloned or rolled-back authenticator read as a server error rather than a failed check.

Relying party configuration

The relying party is fixed in code. Every ceremony is bound to the RP ID passkeybridge.io, and the function accepts only the origins listed against it. An Origin header outside that list is refused with 403 Origin not permitted for passkey operations before any challenge is created, because falling back to the production RP would let an unlisted origin drive a ceremony bound to it.

RP IDAccepted origins
passkeybridge.iohttps://passkeybridge.io, https://www.passkeybridge.io, https://app.passkeybridge.io

https://app.passkeybridge.io is an accepted origin under that RP ID, not an RP ID of its own. The list is a constant in supabase/functions/passkey/index.ts, so adding an origin is a deploy rather than a configuration change, and local development against this function is not supported.

Authenticator selection. attestationType: "none" (no vendor attestation certificate is requested or checked, and no AAGUID allowlist is applied), residentKey: "preferred", userVerification: "preferred".

Passkeys on your own domain. WebAuthn binds a credential to the RP ID at registration, and a browser will only use it on an origin belonging to that RP. Credentials enrolled here therefore sign users into PasskeyBridge and nowhere else. For passkeys under your own RP ID, use the hosted relying party shield-passkey-rp at https://api.passkeybridge.io/v1/shield-passkey-rp: an API key with scope passkey_rp (plus passkey_rp_import for bulk import), Enterprise plan, sandbox keys refused. It runs register_options, register_verify, auth_options, auth_verify, list, delete, delete_user, import_credentials and attest_session against a relying-party configuration your tenant registers first. That configuration row is written by the upsert_rp_config action of shield-admin-mutations, which has no dashboard screen today, so setting up a hosted RP is a support-assisted step.

Challenge storage and expiry

Challenges live in shield_passkey_challenges rather than in function memory, so any isolate can complete a ceremony that another one started.

Lifecycle. A registration or authentication challenge is stored against the user id and that type, and the function deletes the user's previous challenge of the same type first, so rapid repeat clicks cannot leave a stale row that verification would pick up. A sign-in challenge is stored against the all-zero user id under a type of login_ followed by the challenge handle, which is why login_verify has to present that handle back.

Single use. Verification claims the row with a delete that returns it. Exactly one concurrent caller gets the row; the loser sees nothing and gets Challenge already consumed or Challenge expired, so a challenge can never verify twice.

Expiry. The TTL is five minutes, checked in code against the row's created_at as well as by the expires_at column. Every request to the function also fires a best-effort delete of every challenge whose expires_at has passed. No scheduled job cleans this table; the purge only happens while the function is being used.

Binding. A challenge is bound to its type, so a registration challenge cannot complete an authentication, and to its user, except for the sign-in bucket, where the random handle is the binding.

Credential management

Listing. { "action": "list" } returns this user's credentials, newest first:

{
  "passkeys": [
    {
      "id": "b1e0...",
      "credential_id": "<base64url>",
      "device_type": "multiDevice",
      "backed_up": true,
      "transports": ["internal", "hybrid"],
      "created_at": "2026-09-09T10:12:00Z",
      "last_used_at": "2026-09-15T08:03:11Z"
    }
  ]
}

public_key and counter are never selected, so they cannot reach the browser. id is the row id the dashboard uses as a React key; credential_id is what delete takes.

Deleting. { "action": "delete", "credential_id": "..." } deletes the row scoped to both credential_id and the caller's user_id, so one user cannot delete another's credential even with a valid id. The response is { "deleted": true } whether or not a row matched, so a repeat call looks the same as the first one. Deleting the last passkey is allowed; the account falls back to email and password or a social provider.

Checking enrollment. { "action": "check_enrolled" } returns { "enrolled": true, "count": 2 }. The sign-in page calls it right after first email verification to decide whether to offer the enrollment screen.

Confirm it worked. Open Dashboard > Settings > Passkeys. A registered credential appears there with the first 16 characters of its credential id and its registration date, and the section shows an Enabled badge once at least one credential exists.

Dashboard and client integration

The hook. src/hooks/usePasskeys.ts wraps the three actions the dashboard uses and returns { passkeys, registeringPasskey, registerPasskey, deletePasskey }.

const { passkeys, registeringPasskey, registerPasskey, deletePasskey } = usePasskeys();

registerPasskey() runs registration_options, startRegistration, then verify_registration, and refreshes the list. registeringPasskey is true for the whole sequence and disables the button, so a second ceremony cannot start while the first is open. deletePasskey(credentialId) calls delete and refreshes. The list fetch runs on mount and fails silently: if the function is unreachable the Settings tab still renders, without passkey rows.

Dashboard surface. Dashboard > Settings > Passkeys holds the list and the Add passkey button. Removing a credential uses the row's trash control, labelled Remove passkey, and a confirmation dialog titled Remove passkey?.

Sign-in surface. The sign-in page renders Sign in with passkey above the email and password form whenever browserSupportsWebAuthn() is true, and drives login_options, startAuthentication, login_verify, verifyOtp. After first email verification it can show a full-screen enrollment step titled Set up biometric sign-in with Register Passkey and Skip for now.

Browser support is detected at render time with browserSupportsWebAuthn() from @simplewebauthn/browser; when it returns false the passkey controls are not rendered at all. See Passkey UX patterns for the message strings and the patterns that are recommendations rather than shipped behavior.

Security model

Tenant and user isolation. A credential is stored against the tenant resolved from the user's oldest membership. Every session action filters by user_id from the verified JWT, so a valid credential id belonging to someone else is not readable, deletable or usable for step-up.

Origin binding. The request's Origin must be on the allowlist or the ceremony is refused, and the browser independently enforces the RP ID during both registration and authentication. A lookalike domain cannot produce an assertion the verifier accepts, which is the phishing resistance passkeys are bought for.

Challenge replay. Challenges are random, single use, type-bound and expire in five minutes; the claiming delete makes double use impossible even under concurrent requests.

Clone detection. The signature counter is stored and advanced on every verified assertion, and the verifier rejects an assertion whose counter has not advanced. Synced credentials from a cloud provider often report a counter of zero, so this check is weaker in practice for multiDevice credentials than for hardware keys.

Sign-in bootstrap material. login_verify returns a one-time token_hash. It is never logged and is consumed once by verifyOtp; anything that stores or forwards it is handling a credential.

No attestation. Vendor attestation certificates are neither requested nor validated, and the aaguid column is left null on this path, so authenticator-model policies cannot be enforced here. The hosted relying party shield-passkey-rp does record aaguid on the credentials it stores and returns it from its list action.

Limits and rate limiting

No plan gate and no per-user cap. Passkey enrollment is available on every plan. The function does not read the tenant's plan, does not count credentials per user, and does not enforce a minimum or maximum number of passkeys.

Rate limit. The two unauthenticated actions share one distributed limit of 30 requests per minute, keyed on the HMAC-SHA-256 digest of the client IP under the server-held pepper, never the raw address. The session actions are not separately limited by this function.

ScopeLimitWindow
login_options plus login_verify, per hashed client IP30 requests60 seconds

Exceeding it returns 429 with Retry-After: 60 and the headers X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Layer:

{ "error": "Too many requests. Please retry after 60 seconds.", "limit": 30, "remaining": 0, "layer": "distributed" }

The limit is deliberately loose because a discoverable passkey has no password to guess, and an office behind one NAT address shares the bucket.

Body size. Requests above the small-JSON cap answer 413 Payload too large.

Errors

StatusBody errorCause
400Invalid action: and the value sentaction missing or outside the nine
400Invalid JSON bodyBody did not parse
413Payload too largeBody above the small-JSON cap
403Origin not permitted for passkey operationsOrigin header not on the relying-party allowlist
429Too many requests. Please retry after 60 seconds.More than 30 sign-in calls a minute from one hashed IP
401Missing authorization headerA session action with no Authorization header
401Invalid auth tokenThe bearer token did not resolve to a user
401Passkey not recognizedlogin_verify with a credential id that is not stored
403Account is inactiveThe credential's tenant row no longer exists
400Challenge expired—please try againSign-in challenge missing, older than five minutes, or already claimed
400Challenge expired or not foundRegistration challenge missing or older than five minutes
400Challenge already consumedTwo verifications raced for one challenge
401Passkey verification failedThe sign-in assertion did not verify
400Verification failedThe attestation did not verify
400No tenant found—cannot store passkeyEnrolling before the account has a tenant membership
500Internal server errorUnhandled failure. A step-up assertion the verifier throws on is not one: it answers 200 with verified: false

A 404 from https://api.passkeybridge.io/v1/passkey is expected: the function is console-only and is not routed at the public host. See Error codes and troubleshooting for the platform-wide codes.

Related from the blog