Skip to Content
API referencePasskeys (WebAuthn)
Open in Claude Open in ChatGPT

Passkeys (WebAuthn)

Four endpoints implementing WebAuthn for one-tap biometric approval on mobile (plus GET /v1/approvals/passkeys and DELETE /v1/approvals/passkeys/:id, which list and remove the owner’s passkeys for Settings). Built on @simplewebauthn/server .

The flow has two phases:

  1. Enrolment — register/begin then register/finish. Owner-only.
  2. Approval — sign/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 <dashboard-session-jwt>

Response

The standard PublicKeyCredentialCreationOptionsJSON from SimpleWebAuthn:

{ "challenge": "...", "rp": { "id": "app.agentvalet.ai", "name": "AgentValet" }, "user": { "id": "...", "name": "<owner-id>", "displayName": "..." }, "pubKeyCredParams": [...], "authenticatorSelection": { "userVerification": "required", "residentKey": "preferred" }, "timeout": 60000, "attestation": "none" }

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

Errors

StatusCause
401No valid session JWT
429Rate limit (10/min per owner)

POST /v1/approvals/passkeys/register/finish

Verify the attestation and persist the credential.

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

Response — 201 Created

{ "credential_id": "...", "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
400missing response, or verification_failed (challenge mismatch, bad signature, no user verification)
401No valid session JWT
409already_registered — a 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 auth — approval_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": "7c1e4b2a-9d3f-4e8a-b6c5-2f0a1d9e8b34" }

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": "required", "timeout": 60000 }

Errors

StatusCause
400Invalid approval_id shape
404not_found (no such approval) or no_passkeys (the approval’s owner has no enrolled passkeys)
409already_actioned — the approval was already decided
410expired — the approval’s 10-minute window has passed
429Rate limit (10/min per approval)

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 { "approval_id": "7c1e4b2a-9d3f-4e8a-b6c5-2f0a1d9e8b34", "response": { /* AuthenticationResponseJSON from startAuthentication() */ } }

The proxy:

  1. Takes the challenge issued for that approval_id.
  2. Verifies the assertion against the stored public key of one of the approval owner’s passkeys, with user verification required.
  3. Bumps the credential’s counter; if the counter doesn’t strictly increase, verification fails.
  4. Updates approval_queue.status = 'approved'.
  5. Calls reExecuteApproval() to run the original action.
  6. Returns { status }. The agent reads the result through GET /v1/approvals/:id.

Response

{ "status": "approved" }

Errors

StatusCause
400missing fields (approval_id or response), or verification_failed (bad signature, no user verification, counter regression)
401unknown_credential — the passkey isn’t one of the approval owner’s
404Approval not found
409already_actioned — already decided
410expired (approval) or challenge_expired
429Rate limit (10/min per approval)

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 with verification_failed. 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