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.
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.
| Action | Auth | Purpose |
|---|---|---|
login_options | none | Challenge for a discoverable-credential sign-in |
login_verify | none | Verify the assertion and return a session bootstrap token |
registration_options | session JWT | Challenge for enrolling a new credential |
verify_registration | session JWT | Verify the attestation and store the credential |
authentication_options | session JWT | Challenge for a step-up check by a signed-in user |
verify_authentication | session JWT | Verify a step-up assertion and advance the counter |
check_enrolled | session JWT | Whether this user has any credential |
list | session JWT | Credential metadata for this user |
delete | session JWT | Remove 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 ID | Accepted origins |
|---|---|
passkeybridge.io | https://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.
| Scope | Limit | Window |
|---|---|---|
login_options plus login_verify, per hashed client IP | 30 requests | 60 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
| Status | Body error | Cause |
|---|---|---|
| 400 | Invalid action: and the value sent | action missing or outside the nine |
| 400 | Invalid JSON body | Body did not parse |
| 413 | Payload too large | Body above the small-JSON cap |
| 403 | Origin not permitted for passkey operations | Origin header not on the relying-party allowlist |
| 429 | Too many requests. Please retry after 60 seconds. | More than 30 sign-in calls a minute from one hashed IP |
| 401 | Missing authorization header | A session action with no Authorization header |
| 401 | Invalid auth token | The bearer token did not resolve to a user |
| 401 | Passkey not recognized | login_verify with a credential id that is not stored |
| 403 | Account is inactive | The credential's tenant row no longer exists |
| 400 | Challenge expired—please try again | Sign-in challenge missing, older than five minutes, or already claimed |
| 400 | Challenge expired or not found | Registration challenge missing or older than five minutes |
| 400 | Challenge already consumed | Two verifications raced for one challenge |
| 401 | Passkey verification failed | The sign-in assertion did not verify |
| 400 | Verification failed | The attestation did not verify |
| 400 | No tenant found—cannot store passkey | Enrolling before the account has a tenant membership |
| 500 | Internal server error | Unhandled 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
- Credential Chaining: Deriving Trust from a Sequence of Issuer Attestationsengineering · 18 min read
- PCI DSS 4.0.1 and Identity Verification: Stronger Authentication Without Storing Cardholder Datacompliance · 19 min read
- Passkeys Are Not Enough: Why Biometric Binding Needs a Carrier Signal Layersecurity · 11 min read