Passkeys (WebAuthn)
Four endpoints implementing WebAuthn for one-tap biometric approval on mobile. 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 <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
| Status | Cause |
|---|---|
| 401 | No Clerk JWT |
| 429 | Rate 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
| Status | Cause |
|---|---|
| 400 | Verification failed (challenge mismatch, bad signature) |
| 401 | No Clerk JWT |
| 409 | 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": "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
| Status | Cause |
|---|---|
| 400 | Invalid approval_id shape |
| 404 | Approval not found, expired, or owner has no enrolled passkeys |
| 429 | Rate 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:
- Looks up the approval id encoded in the challenge.
- Verifies the assertion against the stored public key.
- Bumps the credential’s
counter; if the counter doesn’t strictly increase, the credential is treated as cloned and the call fails. - Updates
approval_queue.status = 'approved'. - Calls
reExecuteApproval()to run the original action. - Returns the result.
Response
{ "status": "approved", "approval_id": "01HKZ7...", "executed_at": "..." }Errors
| Status | Cause |
|---|---|
| 400 | Missing fields, signature verification failed, or counter regression (suspected clone) |
| 404 | Approval not found |
| 409 | Already decided |
| 410 | Approval expired OR challenge expired |
| 429 | Rate 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.