Skip to Content
API referencePasskeys (WebAuthn)

Passkeys (WebAuthn)

Four endpoints implementing WebAuthn for one-tap biometric approval on mobile. Built on @simplewebauthn/server .

The flow has two phases:

  1. Enrolmentregister/begin then register/finish. Owner-only.
  2. Approvalsign/begin then sign/finish. Triggered by the mobile approval detail page.

Challenges live in a 5-minute in-memory map keyed by owner / approval id. Restart of the proxy invalidates outstanding challenges; users restart the flow.

POST /v1/approvals/passkeys/register/begin

Owner requests options to create a new credential.

POST /v1/approvals/passkeys/register/begin HTTP/1.1 Authorization: Bearer <clerk-jwt>

Response

The standard PublicKeyCredentialCreationOptionsJSON from SimpleWebAuthn:

{ "challenge": "...", "rp": { "id": "app.agentvalet.ai", "name": "AgentValet" }, "user": { "id": "...", "name": "you@example.com", "displayName": "You" }, "pubKeyCredParams": [...], "authenticatorSelection": { "userVerification": "preferred", "residentKey": "preferred" }, "timeout": 60000, "attestation": "none" }

The client passes this to navigator.credentials.create() via SimpleWebAuthn’s startRegistration().

Errors

StatusCause
401No Clerk JWT
429Rate limit (10/min per IP)

POST /v1/approvals/passkeys/register/finish

Verify the attestation and persist the credential.

POST /v1/approvals/passkeys/register/finish HTTP/1.1 Authorization: Bearer <clerk-jwt> Content-Type: application/json { "response": { /* RegistrationResponseJSON from startRegistration() */ }, "device_name": "iPhone 15" }

Response

{ "credential_id": "...", "device_name": "iPhone 15", "verified": true }

On success, a row is inserted into approval_passkeys with the public key, counter (for clone detection), transports, device name, and AAGUID. RLS scopes the row to the owner.

Errors

StatusCause
400Verification failed (challenge mismatch, bad signature)
401No Clerk JWT
409A credential with the same id is already enrolled
410Challenge expired (5-minute window)
429Rate limit

POST /v1/approvals/passkeys/sign/begin

Get assertion options for approving a specific pending approval. No authapproval_id is the capability token. The mobile detail page calls this on mount so the biometric tap is one round-trip when the user clicks Approve.

POST /v1/approvals/passkeys/sign/begin HTTP/1.1 Content-Type: application/json { "approval_id": "01HKZ7..." }

Response

PublicKeyCredentialRequestOptionsJSON, scoped to the credentials enrolled by the approval’s owner:

{ "challenge": "...", "rpId": "app.agentvalet.ai", "allowCredentials": [ { "id": "...", "type": "public-key", "transports": ["internal"] } ], "userVerification": "preferred", "timeout": 60000 }

Errors

StatusCause
400Invalid approval_id shape
404Approval not found, expired, or owner has no enrolled passkeys
429Rate limit

POST /v1/approvals/passkeys/sign/finish

Verify the assertion and decide the approval as approved.

POST /v1/approvals/passkeys/sign/finish HTTP/1.1 Content-Type: application/json { "response": { /* AuthenticationResponseJSON from startAuthentication() */ } }

The proxy:

  1. Looks up the approval id encoded in the challenge.
  2. Verifies the assertion against the stored public key.
  3. Bumps the credential’s counter; if the counter doesn’t strictly increase, the credential is treated as cloned and the call fails.
  4. Updates approval_queue.status = 'approved'.
  5. Calls reExecuteApproval() to run the original action.
  6. Returns the result.

Response

{ "status": "approved", "approval_id": "01HKZ7...", "executed_at": "..." }

Errors

StatusCause
400Missing fields, signature verification failed, or counter regression (suspected clone)
404Approval not found
409Already decided
410Approval expired OR challenge expired
429Rate limit

Counter / clone detection

Every successful sign bumps the credential’s counter. If a future sign provides a counter ≤ the stored value, the authenticator may have been cloned — the proxy rejects the sign and surfaces an audit event. The counter is the standard WebAuthn anti-clone mechanism.

If you see a counter regression in a benign case (e.g. you restored an iCloud backup of your iPhone to a new device), the safest path is to delete the affected credential from Settings → Passkeys and re-enrol.

Last updated on