Self-Service Recovery
Self-service recovery allows users who have lost all passkeys and backup codes to regain access to their Arbitex account without waiting for an administrator. The user requests a recovery link via email, verifies their identity, and enrolls a new passkey in a time-limited restricted session. For admin-initiated recovery, see the Account Recovery guide.
When to Use Self-Service Recovery
Section titled “When to Use Self-Service Recovery”Self-service recovery is appropriate when:
- The user has lost all registered passkeys (device lost, wiped, or stolen)
- The user has no remaining backup codes
- The organization’s passkey policy is set to
required— the user cannot fall back to password-only authentication - The organization has enabled self-service recovery in the OrgRecoveryPolicy
Recovery Flow Overview
Section titled “Recovery Flow Overview”- User requests recovery — submits their email address to
POST /api/auth/recovery/request - System validates eligibility — checks rate limits, account existence, org policy, passkey requirement, and whether the user actually has no passkeys
- Recovery email sent — contains a one-time link with a signed JWT token (1-hour expiry)
- User clicks link — the frontend sends the token to
POST /api/auth/recovery/verify - Token verified — the JWT is validated, the JTI is blacklisted (single-use), and a restricted session token is issued (15-minute expiry)
- Restricted session — the user can only access passkey enrollment endpoints and basic auth routes
- User enrolls a new passkey — calls
POST /api/auth/webauthn/register/beginandPOST /api/auth/webauthn/register/complete - Normal access restored — after enrolling a passkey, the user logs in normally with their new credential
Request Recovery
Section titled “Request Recovery”POST /api/auth/recovery/requestContent-Type: application/json
{ "email": "[email protected]"}Response 200 OK (always):
{ "detail": "If an account exists with that email, a recovery link has been sent."}This endpoint is public (no authentication required).
Anti-Enumeration
Section titled “Anti-Enumeration”The endpoint always returns HTTP 200 with an identical response body regardless of whether:
- The email exists in the system
- The account is active or inactive
- Self-service recovery is enabled for the org
- The user still has passkeys registered
- The rate limit has been exceeded
This prevents attackers from probing the system to discover valid email addresses or account states.
Rate Limiting
Section titled “Rate Limiting”Self-service recovery requests are rate-limited per email address:
| Parameter | Value |
|---|---|
| Max requests | 3 per email |
| Window | 1 hour (sliding) |
| Scope | Per normalized email (lowercase) |
| Enforcement | In-process, thread-safe |
When the limit is exceeded, no email is sent but the response remains the same generic 200.
Eligibility Checks
Section titled “Eligibility Checks”Even if rate limits are not exceeded, the system performs additional checks before sending the recovery email. All checks return the same generic 200 on failure.
| Check | Condition to proceed |
|---|---|
| User exists | Email matches an active user account |
| Policy enabled | OrgRecoveryPolicy.self_service_recovery_enabled is true for the user’s org |
| Passkeys required | OrgPasskeyPolicy.passkey_level is "required" |
| No passkeys registered | The user has zero WebAuthnCredential records |
If any check fails, the flow stops silently. The user receives no email and no error.
Verify Recovery Token
Section titled “Verify Recovery Token”When the user clicks the recovery link, the frontend sends the token for verification:
POST /api/auth/recovery/verifyContent-Type: application/json
{ "token": "eyJhbGciOiJIUzI1NiIs..."}Response 200 OK:
{ "access_token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "bearer", "expires_in": 900}The returned access_token is a restricted session JWT valid for 15 minutes (900 seconds).
Token Security Properties
Section titled “Token Security Properties”| Property | Value |
|---|---|
| Algorithm | HS256 (HMAC-SHA256) |
| Expiry | 60 minutes from creation |
| Single-use | JTI blacklisted immediately on verification |
| Token type claim | "account_recovery" |
Error responses:
| Status | Condition |
|---|---|
| 401 | Token expired, invalid signature, wrong token type, or already used |
Audit Events
Section titled “Audit Events”| Event | When |
|---|---|
auth.recovery_requested |
Recovery email sent (metadata: email, source: "self_service") |
auth.recovery_verified |
Token verified and restricted session issued (metadata: recovery_jti) |
Restricted Session Enforcement
Section titled “Restricted Session Enforcement”The restricted session JWT contains a recovery_session: true claim. The PasskeyEnforcementMiddleware intercepts every request and blocks access to all endpoints except those needed for passkey enrollment.
Allowed Endpoints
Section titled “Allowed Endpoints”| Path | Purpose |
|---|---|
POST /api/auth/webauthn/register/begin |
Start passkey enrollment |
POST /api/auth/webauthn/register/complete |
Complete passkey enrollment |
GET /api/auth/me |
View current user info |
POST /api/auth/logout |
End the session |
/healthz, /health, /ready |
Health checks |
All other endpoints return:
{ "detail": "Recovery session — enroll a passkey to continue", "code": "recovery_session"}Status: 403 Forbidden
Passkey Enrollment Required Mode
Section titled “Passkey Enrollment Required Mode”Separately from recovery sessions, users who log in via password when their org requires passkeys receive a JWT with passkey_enrollment_required: true. This triggers a less restrictive enforcement mode:
| Path | Purpose |
|---|---|
/api/auth/webauthn/* |
All WebAuthn endpoints (register and authenticate) |
/api/auth/recovery/* |
Recovery endpoints |
GET /api/auth/me |
View current user info |
POST /api/auth/logout |
End the session |
/healthz, /health, /ready |
Health checks |
All other endpoints return:
{ "detail": "Passkey enrollment required", "code": "passkey_required"}Status: 403 Forbidden
Global Exemptions
Section titled “Global Exemptions”The following paths always bypass both enforcement modes (recovery session and passkey enrollment):
/.well-known/*/api/auth/login/api/auth/register/api/auth/recovery/*/api/oauth/*/metrics/health,/ready,/healthz
Middleware Behaviour
Section titled “Middleware Behaviour”The PasskeyEnforcementMiddleware is a pure ASGI middleware registered in the application startup. It:
- Checks global exemptions first — exempt paths always pass through
- Decodes the JWT from the
Authorizationheader - If
recovery_sessionistrue, applies recovery session restrictions - If
passkey_enrollment_requiredistrue, applies enrollment restrictions - If the JWT cannot be decoded (expired, invalid, M2M token), the middleware passes the request through and defers to the normal authentication middleware
Passkey Enrollment During Recovery
Section titled “Passkey Enrollment During Recovery”Once the user has a restricted session token, enrollment follows the standard WebAuthn registration flow:
Step 1: Begin Registration
Section titled “Step 1: Begin Registration”POST /api/auth/webauthn/register/beginAuthorization: Bearer <restricted-session-token>Response 200 OK:
{ "options": { "rp": { "name": "Arbitex", "id": "arbitex.ai" }, "challenge": "base64url-encoded-challenge", "pubKeyCredParams": [ { "type": "public-key", "alg": -7 } ], "timeout": 60000, "attestation": "none" }}Step 2: Complete Registration
Section titled “Step 2: Complete Registration”The frontend calls the browser’s WebAuthn API with the options, then sends the attestation response:
POST /api/auth/webauthn/register/completeAuthorization: Bearer <restricted-session-token>Content-Type: application/json
{ "id": "base64url-credential-id", "raw_id": "base64url-raw-id", "response": { "attestationObject": "...", "clientDataJSON": "..." }, "type": "public-key", "name": "My YubiKey"}Response 201 Created — returns a WebAuthnCredentialResponse confirming the new credential.
An auth.webauthn_registered audit event is logged.
After enrollment, the restricted session token is still valid for its remaining time but is no longer useful — the user should log in normally with their new passkey.
Admin Configuration
Section titled “Admin Configuration”OrgRecoveryPolicy
Section titled “OrgRecoveryPolicy”Administrators control self-service recovery through the OrgRecoveryPolicy, which has two independent toggles:
| Field | Type | Default | Description |
|---|---|---|---|
admin_recovery_enabled |
boolean | true |
Whether admins can initiate recovery for users |
self_service_recovery_enabled |
boolean | false |
Whether users can request recovery themselves |
View Current Policy
Section titled “View Current Policy”GET /api/v1/admin/org/recovery-policyAuthorization: Bearer <admin-token>Response 200 OK:
{ "org_id": "org-uuid-...", "admin_recovery_enabled": true, "self_service_recovery_enabled": false, "updated_at": null}If no policy row exists for the organization, the endpoint returns defaults without creating a row. updated_at is null when using defaults.
Update Policy
Section titled “Update Policy”PUT /api/v1/admin/org/recovery-policyAuthorization: Bearer <admin-token>Content-Type: application/json
{ "self_service_recovery_enabled": true}Both fields are optional — only provided fields are updated. The endpoint uses upsert semantics (creates a policy row if none exists).
Security Considerations
Section titled “Security Considerations”Anti-enumeration. The recovery request endpoint is designed to leak zero information about account existence, org policy state, or rate limit status. Every code path returns the same HTTP 200 with the same response body.
Single-use tokens. Recovery tokens are blacklisted immediately upon verification via JTI tracking. A token cannot be used twice, even within its 1-hour validity window.
Time-limited sessions. The restricted session expires after 15 minutes, limiting the window for misuse if a recovery email is intercepted.
Minimal permissions. Recovery sessions grant access only to passkey enrollment and basic auth endpoints. No data access, no admin operations, no API calls beyond what is needed to register a new passkey.
Audit trail. Both the recovery request and token verification are logged as audit events, creating a complete chain of accountability.
Rate limiting. Three requests per email per hour prevents abuse of the recovery flow as a spam vector or enumeration tool.