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.
When Recovery Is Needed
Section titled “When Recovery Is Needed”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.
Recovery Flow Overview
Section titled “Recovery Flow Overview”The full recovery flow proceeds as follows:
- An admin initiates recovery for the user via the admin UI or API, or the user submits a self-service recovery request via email.
- The platform generates a recovery token — a JWT with
type: "account_recovery", a 1-hour expiry, and a unique JTI claim. - The token is delivered by email. In development environments where
SMTP_HOSTis not configured, the token is returned directly in the API response body instead of being sent by email. - The user clicks the link in the recovery email, or manually submits the token to the verification endpoint.
- The platform validates the token: checks type, expiry, and JTI blacklist, then confirms the user account is active.
- A restricted access token is issued. This token has a 15-minute expiry and carries the claim
recovery_session=True. - The restricted session permits only passkey enrollment, profile view, and logout. All other requests return
403 recovery_session. - The user enrolls a new passkey. Full access is restored immediately after successful enrollment.
Admin-Initiated Recovery
Section titled “Admin-Initiated Recovery”Admins can trigger recovery for any active user account from the admin UI or directly via the API.
Admin UI Walkthrough
Section titled “Admin UI Walkthrough”- Navigate to Settings → Users.
- Search for or locate the affected user.
- Open the user detail panel.
- Click Initiate Recovery.
- Confirm the action in the dialog. A recovery email is sent to the user’s registered address.
API Endpoint
Section titled “API Endpoint”POST /api/v1/admin/users/{user_id}/recoveryAuthorization: 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 |
Audit Event
Section titled “Audit Event”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 |
Self-Service Recovery
Section titled “Self-Service Recovery”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.
API Endpoint
Section titled “API Endpoint”POST /api/auth/recovery/requestContent-Type: application/json
{}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.
Audit Event
Section titled “Audit Event”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 |
Recovery Token Verification
Section titled “Recovery Token Verification”Once the user receives a recovery link, they are directed to submit the token.
API Endpoint
Section titled “API Endpoint”POST /api/auth/recovery/verifyContent-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:
- Decode and verify JWT signature (HS256, platform secret).
- Assert
type == "account_recovery". - Assert token is not expired.
- Assert JTI is not present in the blacklist (bloom filter checked first, DB confirmed on hit).
- Load user by
subclaim, assert account is active. - Blacklist the JTI (single-use enforcement).
- 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 |
Audit Event
Section titled “Audit Event”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 |
OrgRecoveryPolicy
Section titled “OrgRecoveryPolicy”Each organization has one OrgRecoveryPolicy record that controls recovery behavior.
Schema
Section titled “Schema”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 |
Admin API
Section titled “Admin API”Get current policy:
GET /api/v1/admin/org/recovery-policyAuthorization: Bearer <admin_token>{ "admin_recovery_enabled": true, "self_service_recovery_enabled": false}Update policy:
PUT /api/v1/admin/org/recovery-policyAuthorization: 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.
Admin UI
Section titled “Admin UI”Navigate to Settings → Security → Recovery Policy to view and edit both flags. Changes take effect immediately for all subsequent recovery requests.
Recovery Token Security
Section titled “Recovery Token Security”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.
Restricted Session Enforcement
Section titled “Restricted Session Enforcement”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 ForbiddenContent-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.
Security Considerations
Section titled “Security Considerations”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.