Skip to content

Account Recovery

Account recovery restores access for users who have lost all registered passkeys and all backup codes. Standard re-authentication is not possible in this state — the recovery flow issues a restricted token that allows only passkey enrollment, returning the account to a fully authenticated state once a new passkey is registered.

Recovery is required when a user:

  • Has lost access to all registered passkeys (device lost, wiped, or unrecoverable)
  • Has exhausted all backup codes
  • Cannot complete any MFA challenge during login

Recovery is the only path back into the account. If even one backup code remains, the user should use it directly from the login screen rather than initiating recovery.

The full recovery flow proceeds as follows:

  1. An admin initiates recovery for the user via the admin UI or API, or the user submits a self-service recovery request via email.
  2. The platform generates a recovery token — a JWT with type: "account_recovery", a 1-hour expiry, and a unique JTI claim.
  3. The token is delivered by email. In development environments where SMTP_HOST is not configured, the token is returned directly in the API response body instead of being sent by email.
  4. The user clicks the link in the recovery email, or manually submits the token to the verification endpoint.
  5. The platform validates the token: checks type, expiry, and JTI blacklist, then confirms the user account is active.
  6. A restricted access token is issued. This token has a 15-minute expiry and carries the claim recovery_session=True.
  7. The restricted session permits only passkey enrollment, profile view, and logout. All other requests return 403 recovery_session.
  8. The user enrolls a new passkey. Full access is restored immediately after successful enrollment.

Admins can trigger recovery for any active user account from the admin UI or directly via the API.

  1. Navigate to Settings → Users.
  2. Search for or locate the affected user.
  3. Open the user detail panel.
  4. Click Initiate Recovery.
  5. Confirm the action in the dialog. A recovery email is sent to the user’s registered address.
POST /api/v1/admin/users/{user_id}/recovery
Authorization: Bearer <admin_token>

Response (200 OK):

{
"message": "Recovery email sent",
"user_id": "usr_01j..."
}

In development mode (no SMTP_HOST), the response also includes "recovery_token": "<jwt>" for direct use in testing.

Error responses:

Status Code Condition
403 admin_recovery_disabled admin_recovery_enabled is false on the org’s OrgRecoveryPolicy
404 user_not_found User ID does not exist
422 user_inactive User account is suspended or deactivated

Every admin-initiated recovery is logged as auth.recovery_initiated with the following fields:

Field Value
event auth.recovery_initiated
admin_id ID of the admin who triggered recovery
target_user_id ID of the user being recovered
ip Admin’s request IP address
timestamp ISO 8601 UTC

Users can request recovery themselves by submitting their email address. This flow is disabled by default and must be explicitly enabled in the org’s recovery policy.

POST /api/auth/recovery/request
Content-Type: application/json
{
"email": "[email protected]"
}

Response (200 OK):

{
"message": "If an account exists for this email, a recovery link has been sent."
}

The response is identical regardless of whether the email address matches an account. This is intentional — it prevents user enumeration.

Rate limit: 3 requests per email address per hour. Requests beyond the limit return 429 Too Many Requests.

If self_service_recovery_enabled is false on the org policy, the endpoint accepts the request and returns the standard 200 response without sending any email or generating a token. This is a deliberate silent no-op to prevent policy disclosure.

auth.recovery_requested
Field Value
event auth.recovery_requested
email_hash SHA-256 of the submitted email (email itself is not logged)
matched_user_id User ID if matched, null if no match
ip Requester IP address
timestamp ISO 8601 UTC

Once the user receives a recovery link, they are directed to submit the token.

POST /api/auth/recovery/verify
Content-Type: application/json
{
"token": "<recovery_jwt>"
}

Response (200 OK):

{
"access_token": "<restricted_access_jwt>",
"token_type": "bearer",
"expires_in": 900
}

The access_token returned here is a restricted session token. It is valid for 15 minutes and carries recovery_session=True. It must be used immediately to enroll a passkey.

Validation sequence:

  1. Decode and verify JWT signature (HS256, platform secret).
  2. Assert type == "account_recovery".
  3. Assert token is not expired.
  4. Assert JTI is not present in the blacklist (bloom filter checked first, DB confirmed on hit).
  5. Load user by sub claim, assert account is active.
  6. Blacklist the JTI (single-use enforcement).
  7. Issue restricted access token.

Error responses:

Status Code Condition
400 invalid_token JWT malformed, wrong type, or signature invalid
400 token_expired Token is past its 1-hour expiry
400 token_used JTI already present in blacklist
422 user_inactive Account deactivated after token was issued
auth.recovery_verified
Field Value
event auth.recovery_verified
user_id User ID recovered
jti Token JTI (for correlation with issuance event)
ip Requester IP address
timestamp ISO 8601 UTC

Each organization has one OrgRecoveryPolicy record that controls recovery behavior.

Table: org_recovery_policies

Column Type Default Description
org_id UUID Foreign key to organizations
admin_recovery_enabled boolean true Allows admins to initiate recovery for users
self_service_recovery_enabled boolean false Allows users to request recovery via email
created_at timestamptz now() Policy creation timestamp
updated_at timestamptz now() Last modification timestamp

Get current policy:

GET /api/v1/admin/org/recovery-policy
Authorization: Bearer <admin_token>
{
"admin_recovery_enabled": true,
"self_service_recovery_enabled": false
}

Update policy:

PUT /api/v1/admin/org/recovery-policy
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"admin_recovery_enabled": true,
"self_service_recovery_enabled": true
}

Both fields must be present in the request body. Partial updates are not supported.

Navigate to Settings → Security → Recovery Policy to view and edit both flags. Changes take effect immediately for all subsequent recovery requests.

Recovery tokens are standard JWTs with additional constraints:

Property Value
Algorithm HS256
Signing key Platform JWT secret
type claim "account_recovery"
Expiry 1 hour
Single-use Yes — JTI blacklisted on first use

The type claim is a purpose-binding guard. The verification endpoint rejects tokens that carry any other type value, preventing cross-purpose token reuse (e.g., a password reset token cannot be submitted to the recovery endpoint).

The JTI blacklist uses a two-tier architecture: a bloom filter for fast in-memory lookups and a database-backed set for definitive confirmation on bloom filter hits. This prevents replay attacks while minimizing per-request database load.

The restricted access token issued after successful verification is enforced by middleware on every authenticated request.

Claim: recovery_session=True

Allowed endpoints:

Path prefix Purpose
/api/auth/webauthn/register/* Passkey enrollment (the intended action)
/api/auth/me View own profile
/api/auth/logout Terminate the session

All other endpoints return:

HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"detail": "Recovery session — enroll a passkey to continue",
"code": "recovery_session"
}

This applies to all resource endpoints, including data APIs, settings endpoints, and admin routes. The restricted session cannot be escalated — the user must complete passkey enrollment to obtain a standard access token.

Anti-enumeration. The self-service recovery endpoint uses constant-time email lookup and dummy hash comparison. Response time and response body are identical for matched and unmatched email addresses, preventing account enumeration via timing or content analysis.

Rate limiting. Self-service recovery is limited to 3 requests per email address per hour. Admin-initiated recovery is subject to standard admin API rate limits. Rate limit state is per-email, not per-IP, to prevent distributed bypass.

Audit trail. All recovery events (auth.recovery_initiated, auth.recovery_requested, auth.recovery_verified) are written to the audit log with admin identity, target user, IP address, and timestamp. These events cannot be suppressed and are included in compliance exports.

Restricted session scope. The recovery session token is cryptographically scoped to passkey enrollment. Even if a recovery token is intercepted and verified by an attacker before the legitimate user, the attacker cannot access any account data or take any action other than enrolling a passkey — which the legitimate user would immediately notice on their next login attempt.

Token blacklisting. JTI blacklisting prevents replay attacks. Once a recovery token has been verified, it cannot be submitted again. If a user’s token is intercepted in transit and used by an attacker, the legitimate user’s subsequent submission will fail with token_used and the user should contact an admin to issue a new token.

Policy disclosure prevention. When self_service_recovery_enabled is false, the request endpoint returns a normal 200 response without indicating that the feature is disabled. This prevents external probing of org security configuration.