Account security
Step-up Authentication
Certain sensitive account operations require the user to re-prove possession of their credentials even when they already hold a valid access token. This is called step-up verification.
Operations That Require Step-up
| Operation | Endpoint |
|---|---|
| Change own password | PUT /profile/password |
| Enable MFA | PUT /profile/mfa/enable |
| Disable MFA | PUT /profile/mfa/disable |
| Regenerate MFA backup codes | POST /profile/mfa/backup-codes |
| Create API key | POST /profile/api_keys |
| Generate IdP link token | POST /profile/idp/{idp_id}/link/token |
| Remove IdP link | POST /profile/idp/{idp_id}/unlink |
Step-up Request Body
All step-up operations accept the following fields in their JSON request body (alongside any operation-specific fields):
| Field | Required | Description |
|---|---|---|
current_password |
Conditionally | Required for accounts with a local password. Omit for SSO-only accounts (no local credential exists) |
mfa_code |
Conditionally | Required when MFA is enabled on the account. Accepts TOTP codes or backup codes |
Example:
{
"current_password": "current-password",
"mfa_code": "123456"
}
SSO-only accounts (no local password) may omit current_password. The password factor is skipped because there is nothing to verify against. If MFA is enabled, the mfa_code is still required regardless of account type.
Step-up Lockout Policy
Failed step-up verifications are tracked per-user with progressive lockout, independent of login lockout counters:
| Failed Attempts | Lockout Duration |
|---|---|
| 5 failures | 5 minutes |
| 10 failures | 30 minutes |
| 15 failures | 2 hours |
The lockout check happens before any password or MFA comparison, so the number of guesses is bounded even when an attacker has a valid access token.
Lockout Response (HTTP 429):
{
"detail": "Too many failed step-up attempts. Try again later."
}
The response also includes a Retry-After header indicating how many seconds remain before the lockout expires.
The counter resets automatically on a successful step-up verification.
Progressive Account Lockout
Endurain implements progressive brute-force protection to prevent credential stuffing attacks. Password login failures use this policy:
| Failed Attempts | Lockout Duration |
|---|---|
| 5 failures | 5 minutes |
| 10 failures | 30 minutes |
| 20 failures | 24 hours |
MFA verification failures use a separate policy:
| Failed Attempts | Lockout Duration |
|---|---|
| 5 failures | 5 minutes |
| 10 failures | 30 minutes |
| 15 failures | 2 hours |
Features:
- Per-username tracking prevents targeted attacks
- Lockout persists through MFA flow (prevents bypass)
- Counter resets on successful authentication
- Graceful error messages with remaining lockout time
MFA Authentication Flow
When Multi-Factor Authentication (MFA) is enabled for a user, the authentication process requires two steps:
Step 1: Initial Login Request
Make a standard login request to /auth/login:
Request:
POST /api/v1/auth/login
Content-Type: application/x-www-form-urlencoded
X-Client-Type: web|mobile
username=user@example.com&password=userpassword
Response (when MFA is enabled):
- Web clients: HTTP 202 Accepted
{
"mfa_required": true,
"username": "example",
"message": "MFA verification required"
}
- Mobile clients: HTTP 200 OK
{
"mfa_required": true,
"username": "example",
"message": "MFA verification required"
}
Step 2: MFA Verification
Complete the login by providing the MFA code (TOTP or backup code) to /auth/mfa/verify:
Request:
POST /api/v1/auth/mfa/verify
Content-Type: application/json
X-Client-Type: web|mobile
{
"username": "user@example.com",
"mfa_code": "123456"
}
Backup Code Format
Users can also use a backup code instead of a TOTP code. Backup codes are in XXXX-XXXX format (e.g., A3K9-7BDF). See MFA Backup Codes for details.
Response (successful verification):
- Web clients: Access token and CSRF token in response body, refresh token as httpOnly cookie
{
"session_id": "unique_session_id",
"access_token": "eyJ...",
"csrf_token": "abc123...",
"token_type": "bearer",
"expires_in": 900,
"refresh_token_expires_in": 604800
}
- Mobile clients: All tokens returned in response body
{
"session_id": "unique_session_id",
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "bearer",
"expires_in": 900,
"refresh_token_expires_in": 604800
}
Error Handling
- No pending MFA login: HTTP 400 Bad Request
{
"detail": "No pending MFA login found for this username"
}
- Invalid MFA code: HTTP 400 Bad Request
{
"detail": "Invalid MFA code, backup code or backup code already used."
}
- Account locked out (too many failures): HTTP 429 Too Many Requests
{
"detail": "Too many failed MFA attempts. Account locked for 300 seconds."
}
Important Notes
- The pending MFA login session is temporary and expires after 5 minutes
- After successful MFA verification, the pending login is automatically cleaned up
- The user must still be active at the time of MFA verification
- If no MFA is enabled for the user, the standard single-step authentication flow applies
MFA Backup Codes
Backup codes provide a recovery mechanism when users lose access to their authenticator app. When MFA is enabled, users receive 10 one-time backup codes that can be used instead of TOTP codes.
Backup Code Format
- Format:
XXXX-XXXX(8 alphanumeric characters with hyphen) - Example:
A3K9-7BDF - Characters: Uppercase letters and digits (excluding ambiguous: 0, O, 1, I)
- One-time use: Each code can only be used once
When Backup Codes Are Generated
- Automatically on MFA Enable: When a user enables MFA, 10 backup codes are generated and returned in the response
- Manual Regeneration: Users can regenerate all backup codes via
POST /profile/mfa/backup-codes(invalidates all previous codes). This endpoint requires step-up verification and MFA must already be enabled on the account.
API Endpoints
| What | URL | Method | Description |
|---|---|---|---|
| Get Backup Code Status | /profile/mfa/backup-codes/status |
GET | Returns count of unused/used codes |
| Regenerate Backup Codes | /profile/mfa/backup-codes |
POST | Generates new codes (invalidates old). Requires step-up JSON body: current_password for local-password accounts and mfa_code when MFA is enabled |
Regenerate Backup Codes Request
POST /api/v1/profile/mfa/backup-codes
Authorization: Bearer {access_token}
X-Client-Type: web
X-CSRF-Token: {csrf_token}
Content-Type: application/json
{
"current_password": "current-password",
"mfa_code": "123456"
}
For SSO-only accounts, current_password may be omitted because there is no local password to verify. If MFA is enabled, mfa_code is required.
Backup Code Status Response
{
"has_codes": true,
"total": 10,
"unused": 8,
"used": 2,
"created_at": "2025-12-21T10:30:00Z"
}
Regenerate Backup Codes Response
{
"codes": [
"A3K9-7BDF",
"X2M5-9NPQ",
"..."
],
"created_at": "2025-12-21T10:30:00Z"
}
Using Backup Codes for Login
Backup codes can be used in the MFA verification step instead of TOTP codes:
POST /api/v1/auth/mfa/verify
Content-Type: application/json
X-Client-Type: web|mobile
{
"username": "user@example.com",
"mfa_code": "A3K9-7BDF"
}
Important
- Backup codes are shown only once when generated - users must save them securely
- Each backup code can only be used once
- Regenerating codes invalidates ALL previous backup codes
- Store backup codes in a secure location separate from your authenticator device