Skip to content

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.

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
  1. User requests recovery — submits their email address to POST /api/auth/recovery/request
  2. System validates eligibility — checks rate limits, account existence, org policy, passkey requirement, and whether the user actually has no passkeys
  3. Recovery email sent — contains a one-time link with a signed JWT token (1-hour expiry)
  4. User clicks link — the frontend sends the token to POST /api/auth/recovery/verify
  5. Token verified — the JWT is validated, the JTI is blacklisted (single-use), and a restricted session token is issued (15-minute expiry)
  6. Restricted session — the user can only access passkey enrollment endpoints and basic auth routes
  7. User enrolls a new passkey — calls POST /api/auth/webauthn/register/begin and POST /api/auth/webauthn/register/complete
  8. Normal access restored — after enrolling a passkey, the user logs in normally with their new credential
POST /api/auth/recovery/request
Content-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).

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.

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.

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.

When the user clicks the recovery link, the frontend sends the token for verification:

POST /api/auth/recovery/verify
Content-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).

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
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)

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.

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

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

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

The PasskeyEnforcementMiddleware is a pure ASGI middleware registered in the application startup. It:

  1. Checks global exemptions first — exempt paths always pass through
  2. Decodes the JWT from the Authorization header
  3. If recovery_session is true, applies recovery session restrictions
  4. If passkey_enrollment_required is true, applies enrollment restrictions
  5. If the JWT cannot be decoded (expired, invalid, M2M token), the middleware passes the request through and defers to the normal authentication middleware

Once the user has a restricted session token, enrollment follows the standard WebAuthn registration flow:

POST /api/auth/webauthn/register/begin
Authorization: Bearer <restricted-session-token>

Response 200 OK:

{
"options": {
"rp": { "name": "Arbitex", "id": "arbitex.ai" },
"user": { "id": "...", "name": "[email protected]", "displayName": "User" },
"challenge": "base64url-encoded-challenge",
"pubKeyCredParams": [ { "type": "public-key", "alg": -7 } ],
"timeout": 60000,
"attestation": "none"
}
}

The frontend calls the browser’s WebAuthn API with the options, then sends the attestation response:

POST /api/auth/webauthn/register/complete
Authorization: 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.

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
GET /api/v1/admin/org/recovery-policy
Authorization: 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.

PUT /api/v1/admin/org/recovery-policy
Authorization: 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).

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.