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:
- Enrolment —
register/beginthenregister/finish. Owner-only. - Approval —
sign/beginthensign/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
| Status | Cause |
|---|---|
| 401 | No valid session JWT |
| 429 | Rate 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
| Status | Cause |
|---|---|
| 400 | missing response, or verification_failed (challenge mismatch, bad signature, no user verification) |
| 401 | No valid session JWT |
| 409 | already_registered — a credential with the same id is already enrolled |
| 410 | Challenge expired (5-minute window) |
| 429 | Rate 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
| Status | Cause |
|---|---|
| 400 | Invalid approval_id shape |
| 404 | not_found (no such approval) or no_passkeys (the approval’s owner has no enrolled passkeys) |
| 409 | already_actioned — the approval was already decided |
| 410 | expired — the approval’s 10-minute window has passed |
| 429 | Rate 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:
- Takes the challenge issued for that
approval_id. - Verifies the assertion against the stored public key of one of the approval owner’s passkeys, with user verification required.
- Bumps the credential’s
counter; if the counter doesn’t strictly increase, verification fails. - Updates
approval_queue.status = 'approved'. - Calls
reExecuteApproval()to run the original action. - Returns
{ status }. The agent reads the result throughGET /v1/approvals/:id.
Response
{ "status": "approved" }Errors
| Status | Cause |
|---|---|
| 400 | missing fields (approval_id or response), or verification_failed (bad signature, no user verification, counter regression) |
| 401 | unknown_credential — the passkey isn’t one of the approval owner’s |
| 404 | Approval not found |
| 409 | already_actioned — already decided |
| 410 | expired (approval) or challenge_expired |
| 429 | Rate 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.