Handling authentication
Endurain supports integration with other apps through a comprehensive OAuth 2.1 compliant authentication system that includes standard username/password authentication, Multi-Factor Authentication (MFA), OAuth/SSO integration, and JWT-based session management with refresh token rotation.
API Requirements
- Client Type Header: Most protected JWT requests must include an
X-Client-Typeheader with eitherwebormobileas the value. Authentication, public, browser-redirect, API-key, and activity-upload unified-auth flows follow the endpoint-specific requirements below. - OAuth/PKCE Client Type Binding: For
/public/idp/login/{idp_slug}and password PKCE login, the backend records aclient_typeon the PKCE/OAuth state. During/public/idp/session/{session_id}/tokens, that stored value controls the response shape. The token-exchangeX-Client-Typeheader is optional, but if provided it must match the stored value; otherwise the exchange returns400withclient_type does not match the OAuth state. - Authorization: Most protected JWT requests must include an
Authorization: Bearer <access token>header with a valid access token. Login, MFA verification, OAuth initiation/callback/token exchange, password reset, sign-up, and activity uploads authenticated with an API key are exceptions. - CSRF Protection (Web Only): State-changing protected web requests (
POST,PUT,DELETE,PATCH) must include anX-CSRF-Tokenheader. This custom header requirement prevents simple cross-site form submissions and is combined with short-lived in-memory access tokens,SameSite=Strictrefresh cookies, and CORS controls./auth/refreshsupports a bootstrap refresh without this header after page reload; if the header is provided and the session has a stored CSRF binding, the token value must be valid.
API Key Authentication
Certain endpoints support API key authentication as an alternative to Bearer + X-Client-Type headers. API key requests do not require the X-Client-Type header or a CSRF token. See API Key Authentication for details.
Token Handling
Token Lifecycle
- The backend generates an
access_tokenvalid for 15 minutes (default) and arefresh_tokenvalid for 7 days (default). - The
access_tokenis used for authorization; therefresh_tokenis used to obtain new access tokens. - A
csrf_tokenis generated for CSRF protection on state-changing requests. - Token expiration times can be customized via environment variables (see Configuration section below).
OAuth 2.1 Token Storage Model (Hybrid Approach)
Endurain implements an OAuth 2.1 compliant hybrid token storage model that provides both security and usability:
| Token | Storage Location | Lifetime | Security Purpose |
|---|---|---|---|
| Access Token | In-memory (JavaScript) | 15 minutes | Short-lived, XSS-resistant (not persisted) |
| Refresh Token | httpOnly cookie | 7 days | CSRF-protected, auto-sent by browser |
| CSRF Token | In-memory (JavaScript) | Session | Prevents CSRF attacks on state-changing requests |
Security Properties:
- XSS Protection: Access tokens stored in memory cannot be exfiltrated via XSS attacks
- CSRF Protection: Refresh token in an httpOnly
SameSite=Strictcookie plus the web-only CSRF header requirement prevents simple cross-site state changes./auth/refreshalso validates the CSRF token value when the client sends it and the session has a stored CSRF binding. - Session Persistence: Page reload triggers
/auth/refreshwith httpOnly cookie to restore tokens - Multi-tab Support: httpOnly cookie shared across browser tabs
Token Delivery by Client Type
-
For web apps:
- Access token and CSRF token returned in JSON response body (stored in-memory)
- Refresh token set as httpOnly cookie (
endurain_refresh_token) - On page reload, call
/auth/refreshto restore in-memory tokens
-
For mobile apps:
- Password login without PKCE returns access and refresh tokens in the JSON response body
- Password login with PKCE and OAuth/SSO return a
session_idfirst; the app exchanges that session with its PKCE verifier to receive access and refresh tokens- Store tokens in secure platform storage (iOS Keychain, Android EncryptedSharedPreferences)
Authentication Flows
Standard Login Flow (Username/Password)
- Client sends credentials to
/auth/loginendpoint - Backend validates credentials and checks for account lockout
- If MFA is enabled, backend returns MFA-required response
- If MFA is disabled or verified, backend generates tokens
- Tokens are delivered based on client type:
- Web: Access token + CSRF token in response body, refresh token as httpOnly cookie
- Mobile: All tokens in response body (CSRF not included, not needed for mobile logic)
- Mobile with PKCE: Session ID for secure token exchange (see Mobile Password Login with PKCE below)
OAuth/SSO Flow
- Client requests list of enabled providers from
/public/idp - Client initiates OAuth at
/public/idp/login/{idp_slug}with a PKCE challenge. Mobile clients should includeX-Client-Type: mobile; if the header is absent or invalid, the backend records the flow asweb. - User authenticates with the OAuth provider
- Provider redirects back to
/public/idp/callback/{idp_slug}with authorization code - Backend exchanges code for provider tokens and user info
- Backend creates or links the user account, creates a session, and redirects the client with a
session_id - The client exchanges the session for tokens via the PKCE token exchange endpoint
/public/idp/session/{session_id}/tokens. The exchange response follows the stored client type; anX-Client-Typeheader may be sent for clarity, but conflicting values are rejected: - Web clients: Access token + CSRF token in response body, refresh token as httpOnly cookie
- Mobile clients: Access token + refresh token in response body
Token Refresh Flow
The token refresh flow implements OAuth 2.1 compliant refresh token rotation:
- When access token expires, client calls
POST /auth/refresh:- Web clients: Include
X-CSRF-Tokenheader with the current CSRF token, except bootstrap refresh after page reload may omit it - Mobile clients: Include refresh token in request
- Web clients: Include
- Backend validates refresh token and session, checks for token reuse
- If token reuse detected: Entire token family is invalidated (security breach response)
- New tokens are generated (access, refresh, CSRF) with refresh token rotation
- Old refresh token is stored for reuse detection (grace period: 60 seconds)
- Response includes new tokens; web clients receive new httpOnly cookie
Token Refresh Request (Web):
POST /api/v1/auth/refresh
X-Client-Type: web
X-CSRF-Token: {current_csrf_token}
Cookie: endurain_refresh_token={refresh_token}
Token Refresh Response (Web):
{
"session_id": "uuid",
"access_token": "eyJ...",
"csrf_token": "new_csrf_token",
"token_type": "bearer",
"expires_in": 900,
"refresh_token_expires_in": 604800
}
Token Refresh Response (Mobile):
{
"session_id": "uuid",
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "bearer",
"expires_in": 900,
"refresh_token_expires_in": 604800
}
Refresh Token Rotation & Reuse Detection
Endurain implements automatic refresh token rotation with reuse detection to prevent token theft:
| Security Feature | Description |
|---|---|
| Automatic Rotation | New refresh token issued on every /auth/refresh call |
| Token Family Tracking | All tokens in a session share a token_family_id |
| Reuse Detection | Old tokens are stored and monitored for reuse |
| Grace Period | 60-second window allows for network retry scenarios |
| Family Invalidation | If reuse detected, ALL tokens in family are invalidated |
| Rotation Count | Tracks number of rotations for audit purposes |
API Key Authentication Flow
For integrations that do not maintain a JWT session, API keys provide a stateless alternative:
- Generate an API key via the web UI (Settings → Security → API Keys) or the management API (requires JWT)
- Store the raw key securely — it is shown only once at creation time
- Include the key in requests to supported endpoints via the
X-API-Keyheader or the?api_key=query parameter - The backend validates the key hash, checks revocation and expiry, and resolves the user identity and scopes
- The endpoint processes the request if the required scope is present in the key's grant
See API Key Authentication for the complete reference.
API Endpoints
The API is reachable under /api/v1. Below are the authentication-related endpoints. Complete API documentation is available on the backend docs (http://localhost:98/api/v1/docs or http://ip_address:98/api/v1/docs or https://domain/api/v1/docs):
Core Authentication Endpoints (Web)
| What | Url | Expected Information | Rate Limit |
|---|---|---|---|
| Authorize | /auth/login |
Header: X-Client-Type: web; FORM with fields: username, password. HTTPS highly recommended |
10 requests/min |
| Refresh Token | /auth/refresh |
Header: X-Client-Type: web; Cookie: endurain_refresh_token; optional Header: X-CSRF-Token (bootstrap logic) |
30 requests/min |
| Verify MFA | /auth/mfa/verify |
Header: X-Client-Type: web; JSON {'username': <username>, 'mfa_code': '123456'} |
10 requests/min |
| Logout | /auth/logout |
Header: X-Client-Type: web; Cookie: endurain_refresh_token |
30 requests/min |
Core Authentication Endpoints (Mobile)
| What | Url | Expected Information | Rate Limit |
|---|---|---|---|
| Authorize | /auth/login |
Header: X-Client-Type: mobile; FORM with fields: username, password. Optional query params: code_challenge, code_challenge_method (mobile PKCE). HTTPS highly recommended |
10 requests/min |
| Refresh Token | /auth/refresh |
Header: X-Client-Type: mobile; Header: Authorization: Bearer <Refresh Token> |
30 requests/min |
| Verify MFA | /auth/mfa/verify |
Header: X-Client-Type: mobile; JSON body: {'username': <username>, 'mfa_code': '123456'}. Optional query params: code_challenge, code_challenge_method (mobile PKCE) |
10 requests/min |
| Logout | /auth/logout |
Header: X-Client-Type: mobile; Header: Authorization: Bearer <Refresh Token> |
30 requests/min |
OAuth/SSO Endpoints
| What | Url | Expected Information | Rate Limit |
|---|---|---|---|
| Get Enabled Providers | /public/idp |
None (public endpoint) | - |
| Initiate OAuth Login | /public/idp/login/{idp_slug} |
Header: X-Client-Type (web or mobile). For mobile OAuth/PKCE this must be mobile; query params: redirect, code_challenge, code_challenge_method |
10 requests/min per IP |
| OAuth Callback | /public/idp/callback/{idp_slug} |
Query params: code=<code>, state=<state> |
10 requests/min per IP |
| Token Exchange (PKCE) | /public/idp/session/{session_id}/tokens |
JSON: {"code_verifier": "<verifier>"} (password PKCE or SSO PKCE). Optional Header: X-Client-Type; if present, it must match the client type stored on the PKCE/OAuth state. |
10 requests/min |
| Create IdP Link Token | /profile/idp/{idp_id}/link/token |
Requires authenticated session and step-up JSON body: current_password for local-password accounts; mfa_code when MFA is enabled. Returns a 60-second one-time link token |
10 requests/min |
| Link IdP to Account | /profile/idp/{idp_id}/link?link_token=<token> |
Browser redirect endpoint using the one-time link token | - |
Session Management Endpoints
| What | Url | Expected Information |
|---|---|---|
| Get User Sessions | /sessions/user/{user_id} |
Headers: Authorization: Bearer <Access Token>, X-Client-Type; requires sessions:read scope |
| Delete Session | /sessions/{session_id}/user/{user_id} |
Headers: Authorization: Bearer <Access Token>, X-Client-Type; requires sessions:write scope; web clients must also include X-CSRF-Token |
API Key Management Endpoints
Require JWT authentication with the standard X-Client-Type requirement; state-changing web requests must include X-CSRF-Token. API keys cannot manage other API keys.
| Method | Url | Description |
|---|---|---|
GET |
/profile/api_keys |
List all API keys for the authenticated user |
POST |
/profile/api_keys |
Create a new API key |
PATCH |
/profile/api_keys/{id}/revoke |
Revoke (deactivate) a key |
DELETE |
/profile/api_keys/{id} |
Permanently delete a key |
Example Resource Endpoints
| What | Url | Expected Information |
|---|---|---|
| Activity Upload | /activities/create/upload |
.gpx, .tcx, .gz or .fit file. Accepts JWT or API key (X-API-Key header / ?api_key= query param) with activities:upload scope. This unified-auth endpoint does not require X-Client-Type |
| Set Weight | /health/weight |
JSON {'weight': <number>, 'created_at': 'yyyy-MM-dd'} |
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
API Key Authentication
API keys provide a stateless, long-lived authentication mechanism for programmatic access and third-party integrations. Unlike JWT sessions, API keys do not require a login flow or token refresh, and are scoped to specific operations.
Security Notice
API keys are powerful credentials. Treat them like passwords: store them securely and never expose them in client-side code, public repositories, or logs.
Key Format
API keys use the following format:
endurain_<43-character-base64url-random-string>
- Prefix:
endurain_— identifies Endurain API keys in secret scanning tools (e.g., GitHub secret scanning) - Random part: 256 bits of cryptographically secure random data (
secrets.token_urlsafe(32)), encoded as a 43-character base64url string - Total length: ~52 characters
The raw key is shown once at creation time and is never stored by the server (only the SHA-256 hash is stored). If lost, the key must be deleted and a new one created.
Scopes
API keys are granted one or more API-key scopes at creation time. Currently only activities:upload is accepted; JWT-only scopes such as profile, activities:read, or users:write cannot be granted to API keys.
The key-management API rejects any requested scope outside the API-key allow-list. This keeps long-lived API keys limited to the endpoint that currently supports API-key authentication.
| Scope | Description |
|---|---|
activities:upload |
Upload activity files (.gpx, .tcx, .fit, .gz) |
How to Authenticate with an API Key
API keys bypass the standard JWT and X-Client-Type requirements. Send the raw key using either option:
Option 1: X-API-Key header (recommended):
POST /api/v1/activities/create/upload
X-API-Key: endurain_abc12345...
Content-Type: multipart/form-data
(file body)
Option 2: api_key query parameter (for tools that cannot set custom headers):
POST /api/v1/activities/create/upload?api_key=endurain_abc12345...
Content-Type: multipart/form-data
(file body)
Header vs Query Parameter
The X-API-Key header is strongly preferred. Query parameters may appear in server access logs, reverse-proxy logs, and browser history, increasing the risk of key exposure. Use the query parameter only when setting custom headers is not possible.
If both the X-API-Key header and the ?api_key= query parameter are present, the header takes precedence.
Endpoints That Accept API Keys
| Method | Endpoint | Required Scope | Description |
|---|---|---|---|
POST |
/activities/create/upload |
activities:upload |
Upload a .gpx, .tcx, .fit, or .gz activity file |
The upload endpoint also accepts JWT authentication through the same unified-auth dependency. JWT upload requests still require Authorization: Bearer <access token> and the activities:upload scope, but this endpoint does not require X-Client-Type.
Managing API Keys
API keys are managed through the Endurain web UI (Settings → Security → API Keys) or via the REST API. All management endpoints require a valid JWT access token.
Creating an API key also requires step-up verification because the key is a long-lived credential:
- Local-password accounts must provide
current_password - Accounts with MFA enabled must also provide
mfa_code - SSO-only accounts may omit
current_passwordbecause there is no local password to verify
Create API Key Request:
POST /api/v1/profile/api_keys
Authorization: Bearer {access_token}
X-Client-Type: web
X-CSRF-Token: {csrf_token}
Content-Type: application/json
{
"name": "Home Server Integration",
"scopes": ["activities:upload"],
"expires_at": "2027-01-01T00:00:00Z",
"current_password": "current-password",
"mfa_code": "123456"
}
| Field | Required | Description |
|---|---|---|
name |
Yes | Human-readable label (max 100 characters) |
scopes |
Yes | Array of API-key scopes to grant. Currently only activities:upload is accepted |
expires_at |
No | ISO 8601 expiry datetime. Omit or null for no expiry |
current_password |
Conditionally | Required for accounts with a local password. Omit for SSO-only accounts |
mfa_code |
Conditionally | Required when MFA is enabled on the account |
Create API Key Response (HTTP 201):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": 1,
"name": "Home Server Integration",
"key_prefix": "abc12345",
"scopes": "[\"activities:upload\"]",
"expires_at": "2027-01-01T00:00:00Z",
"last_used_at": null,
"created_at": "2026-03-02T10:00:00Z",
"is_active": true,
"key": "endurain_abc12345..."
}
Save the key immediately
The key field is returned only in this response. It is not stored by the server and cannot be retrieved later. Store it in a secure location (e.g., a password manager or secrets vault) before dismissing the response.
List API Keys Response (GET /profile/api_keys):
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": 1,
"name": "Home Server Integration",
"key_prefix": "abc12345",
"scopes": "[\"activities:upload\"]",
"expires_at": "2027-01-01T00:00:00Z",
"last_used_at": "2026-03-01T08:30:00Z",
"created_at": "2026-03-02T10:00:00Z",
"is_active": true
}
]
The key field is never returned in list or subsequent get responses — only key_prefix is shown for identification.
Revoke API Key (PATCH /profile/api_keys/{id}/revoke):
Revocation soft-deletes the key by setting is_active = false. Revoked keys are rejected immediately but remain visible in the list (useful for audit purposes). Returns HTTP 204 on success.
Delete API Key (DELETE /profile/api_keys/{id}):
Permanently removes the key from the database. Returns HTTP 204 on success.
Security Properties
| Property | Detail |
|---|---|
| Storage | SHA-256 hex digest stored server-side; raw key never persisted |
| Comparison | Constant-time (hmac.compare_digest) prevents timing attacks |
| Revocation | Immediate — revoked keys are rejected at validation time |
| Expiry | Optional; expired keys are rejected at validation time with timezone-aware comparison |
| Audit logging | Every successful authentication is logged with key prefix, user ID, endpoint, and client IP |
| No self-escalation | An API key cannot create, list, or revoke other API keys (JWT required) |
| Minimum privilege | Keys carry only the scopes explicitly granted at creation time |
OAuth/SSO Integration
Supported Identity Providers
Endurain supports OAuth/SSO integration with various identity providers out of the box:
- Authelia
- Authentik
- Casdoor
- Keycloak
- Pocket ID
The system is extensible and can be configured to work with:
- GitHub
- Microsoft Entra ID
- Others/custom OIDC providers
OAuth Configuration
Identity providers must be configured with the following parameters:
client_id: OAuth client identifierclient_secret: OAuth client secretauthorization_endpoint: Provider's authorization URLtoken_endpoint: Provider's token exchange URLuserinfo_endpoint: Provider's user information URLredirect_uri: Callback URL (typically/public/idp/callback/{idp_slug})
Linking Accounts
Users can link their Endurain account to an OAuth provider:
- User must be authenticated with a valid session
- Create a one-time link token with
POST /profile/idp/{idp_id}/link/token. Local-password accounts must includecurrent_password; accounts with MFA enabled must also includemfa_code. SSO-only accounts may omitcurrent_password. - Open
/profile/idp/{idp_id}/link?link_token=<token>in the browser before the token expires (60 seconds) - Authenticate with the identity provider
- Provider is linked to the existing account
OAuth Token Response
When authenticating via OAuth, the response format matches the standard authentication:
- Web clients: Redirected to the frontend with a
session_id; the frontend exchanges the session and receives the access token + CSRF token in the response body, while the refresh token is set as an httpOnly cookie - Mobile clients: Redirected or deep-linked with a
session_id; the app exchanges the session and receives access + refresh tokens in the response body
Mobile OAuth/SSO
OAuth/SSO login requires PKCE for all clients. Mobile apps should still prefer the system browser flow because it keeps provider credentials outside the app process.
Mobile Password Login with PKCE
Overview
Mobile apps can use PKCE (Proof Key for Code Exchange, RFC 7636) for password authentication, providing enhanced security by preventing token interception in WebViews. This flow mirrors the OAuth/SSO PKCE flow but for local password authentication.
Why Use PKCE for Password Login?
| Traditional Mobile Password Login | PKCE Password Login |
|---|---|
| Tokens returned in response | Tokens exchanged via secure API |
| Tokens visible in WebView context | Only session_id visible |
| Potential token interception | No token in response body |
| Simple but less secure | Secure, requires verifier |
Step-by-Step PKCE Password Implementation
Step 1: Generate PKCE Code Verifier and Challenge
Before sending credentials, generate a cryptographically random code verifier and compute its SHA256 challenge (same as Mobile SSO with PKCE):
code_challenge = BASE64URL(SHA256(code_verifier))
Step 2: Send Login Request with PKCE Parameters
Include the code challenge in the login request:
Login Request with PKCE:
POST /api/v1/auth/login?code_challenge={challenge}&code_challenge_method=S256
Content-Type: application/x-www-form-urlencoded
X-Client-Type: mobile
username=user@example.com&password=userpassword
Form Parameters:
| Parameter | Required | Description |
|---|---|---|
username |
Yes | Username or email |
password |
Yes | User's password |
Query Parameters:
| Parameter | Required | Description |
|---|---|---|
code_challenge |
Yes (PKCE) | Base64url-encoded SHA256 hash of code_verifier |
code_challenge_method |
Yes (PKCE) | Must be S256 |
Successful Response (HTTP 200):
Instead of tokens, receive a session_id for token exchange:
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"mfa_required": false,
"message": "Complete authentication by exchanging tokens at /public/idp/session/{session_id}/tokens"
}
Step 3: Exchange Session for Tokens (PKCE Verification)
Use the code verifier to securely exchange the session for tokens:
Token Exchange Request:
POST /api/v1/public/idp/session/{session_id}/tokens
Content-Type: application/json
X-Client-Type: mobile
{
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}
Successful Response (HTTP 200):
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 900,
"refresh_token_expires_in": 604800,
"token_type": "Bearer"
}
Error Responses:
| Status | Error | Description |
|---|---|---|
| 400 | Invalid code_verifier | Verifier doesn't match the challenge |
| 400 | client_type does not match the OAuth state |
X-Client-Type at token exchange differs from the value stored on the PKCE/OAuth state |
| 404 | Session not found | Invalid session_id |
| 409 | Tokens already exchanged | Replay attack prevention |
| 429 | Rate limit exceeded | Max 10 requests/minute per IP |
Step 4: Store Tokens Securely
Store the received tokens in secure platform storage:
- iOS: Keychain Services
- Android: EncryptedSharedPreferences or Android Keystore
Step 5: Use Tokens for API Requests
Use the tokens for authenticated API calls (same as Mobile SSO with PKCE).
Backward Compatibility
Mobile clients that don't provide PKCE parameters will receive tokens directly (legacy behavior):
POST /api/v1/auth/login
Content-Type: application/x-www-form-urlencoded
X-Client-Type: mobile
username=user@example.com&password=userpassword
Responds with tokens directly (no PKCE):
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 900,
"refresh_token_expires_in": 604800
}
With MFA Enabled
If the user has MFA enabled, the flow includes an additional MFA verification step:
- Client sends login with PKCE parameters
- Backend returns
"mfa_required": truewith message - Client collects MFA code from user
- Client sends MFA code to
/auth/mfa/verifywith PKCE parameters - Backend verifies MFA and returns session_id (for PKCE) or tokens directly
- Client exchanges session_id for tokens using code_verifier
MFA Verification with PKCE:
POST /api/v1/auth/mfa/verify?code_challenge={challenge}&code_challenge_method=S256
Content-Type: application/json
X-Client-Type: mobile
{
"username": "user@example.com",
"mfa_code": "123456"
}
Response (Session ID for Exchange):
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"mfa_required": false,
"message": "Complete authentication by exchanging tokens at /public/idp/session/{session_id}/tokens"
}
Then exchange for tokens as in Step 3 above.
Security Features
| Feature | Description |
|---|---|
| PKCE S256 | SHA256 challenge prevents code interception |
| One-time exchange | Tokens can only be exchanged once per session |
| 10-minute exchange window | The PKCE/OAuth state expires after 10 minutes; the pending session cannot be exchanged after that window |
| Rate limiting | 10 token exchange requests per minute |
| Session binding | Session is cryptographically bound to PKCE challenge |
Mobile SSO with PKCE
Overview
PKCE (Proof Key for Code Exchange, RFC 7636) is required for OAuth/SSO authentication. It provides enhanced security by eliminating the need to extract tokens from WebView cookies, preventing authorization code interception attacks, and enabling a cleaner separation between browser/WebView and app contexts.
Why Use PKCE?
| Traditional WebView Flow | PKCE Flow |
|---|---|
| Extract tokens from cookies | Tokens delivered via secure API |
| Cookies may leak across contexts | No cookie extraction needed |
| Complex WebView cookie management | Simple token exchange |
| Potential timing issues | Atomic token exchange |
PKCE Flow Overview
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile App │ │ Backend │ │ WebView │ │ IdP │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │ │
│ Generate verifier │ │ │
│ & challenge │ │ │
│──────────────────>│ │ │
│ │ │ │
│ Open WebView with challenge │ │
│──────────────────────────────────────>│ │
│ │ │ │
│ │ Redirect to IdP │
│ │──────────────────────────────────────>│
│ │ │ │
│ │ │ User logs in │
│ │ │<─────────────────>│
│ │ │ │
│ │ Callback with code & state │
│ │<──────────────────────────────────────│
│ │ │ │
│ Redirect with session_id │ │
│<──────────────────────────────────────│ │
│ │ │ │
│ POST token exchange with verifier │ │
│──────────────────>│ │ │
│ │ │ │
│ Return tokens │ │ │
│<──────────────────│ │ │
│ │ │ │
Step-by-Step PKCE Implementation
Step 1: Generate PKCE Code Verifier and Challenge
Before initiating the OAuth flow, generate a cryptographically random code verifier and compute its SHA256 challenge:
Code Verifier Requirements (RFC 7636):
- Length: 43-128 characters
- Characters accepted by Endurain:
A-Z,a-z,0-9,-,_ - Cryptographically random
Code Challenge Computation:
code_challenge = BASE64URL(SHA256(code_verifier))
Step 2: Initiate OAuth with PKCE Challenge
Initiate OAuth from an app-controlled HTTP request so the backend records X-Client-Type: mobile, then open the returned redirect target in WebView.
Initiate Request:
GET /api/v1/public/idp/login/{idp_slug}?code_challenge={challenge}&code_challenge_method=S256&redirect=/dashboard
X-Client-Type: mobile
The response is a redirect to the IdP authorization URL. Load that redirect target in WebView.
Direct Initiate URL (reference):
https://your-endurain-instance.com/api/v1/public/idp/login/{idp_slug}?code_challenge={challenge}&code_challenge_method=S256&redirect=/dashboard
Query Parameters:
| Parameter | Required | Description |
|---|---|---|
code_challenge |
Yes (PKCE) | Base64url-encoded SHA256 hash of code_verifier |
code_challenge_method |
Yes (PKCE) | Must be S256 |
redirect |
No | Frontend path after successful login |
Step 3: Monitor WebView for Callback
The OAuth flow proceeds as normal. Monitor the WebView URL for the success redirect:
Success URL Pattern:
https://your-endurain-instance.com/login?sso=success&session_id={uuid}&redirect=/dashboard
Extract the session_id from the URL - this is needed for token exchange.
Step 4: Exchange Session for Tokens (PKCE Verification)
After obtaining the session_id, close the WebView and exchange it for tokens using the code verifier:
Token Exchange Request:
POST /api/v1/public/idp/session/{session_id}/tokens
Content-Type: application/json
X-Client-Type: mobile
{
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}
Successful Response (HTTP 200):
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 900,
"refresh_token_expires_in": 604800,
"token_type": "Bearer"
}
Error Responses:
| Status | Error | Description |
|---|---|---|
| 400 | Invalid code_verifier | Verifier doesn't match the challenge |
| 400 | client_type does not match the OAuth state |
X-Client-Type at token exchange differs from the value stored on the PKCE/OAuth state |
| 404 | Session not found | Invalid session_id or not a PKCE flow |
| 409 | Tokens already exchanged | Replay attack prevention |
| 429 | Rate limit exceeded | Max 10 requests/minute per IP |
Step 5: Store Tokens Securely
Store the received tokens in secure platform storage:
- iOS: Keychain Services
- Android: EncryptedSharedPreferences or Android Keystore
Step 6: Use Tokens for API Requests
Use the tokens for authenticated API calls:
GET /api/v1/activities
Authorization: Bearer {access_token}
X-Client-Type: mobile
System Browser Alternative (RFC 8252 Recommended)
For maximum security, mobile apps should use the system browser instead of an embedded WebView. This follows RFC 8252 - OAuth 2.0 for Native Apps.
Advantages over WebView
| WebView | System Browser |
|---|---|
| Full page rendered in-app | OS-managed, isolated process |
| Cannot verify address bar URL | Address bar visible to user (phishing resistance) |
| Cookies shared with app | No app access to browser storage |
| App must extract session_id from URL | App receives session_id via deep-link |
System Browser Flow
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mobile App │ │ Endurain Backend│ │System Browser│ │ IdP │
└──────┬──────┘ └────────┬────────┘ └──────┬──────┘ └──────┬──────┘
│ │ │ │
│ 1. Generate PKCE │ │ │
│ verifier+challenge │ │
│ │ │ │
│ 2. Open system browser to: │ │
│ /public/idp/login/{slug} │ │
│ ?code_challenge=X │ │
│ &redirect={custom_scheme}://callback │ │
│────────────────────────────────────────────────────────────────────>
│ │ │ │
│ │ 3. Validate redirect │ │
│ │ Store in DB │ │
│ │──────────────────────> │
│ │ Redirect to IdP │
│ │────────────────────────────────────────>│
│ │ │ │
│ │ │ User logs in │
│ │ │<─────────────────>│
│ │ code + state │ │
│ │<────────────────────────────────────────│
│ │ │ │
│ │ 4. Redirect to frontend: │
│ │ /login?sso=success&session_id=UUID │
│ │ &redirect={custom_scheme}://callback │
│ │ &external_redirect=true │
│ │────────────────────────────────────────>│
│ │ │ │
│ │ 5. Frontend detects external_redirect │
│ │ Forwards to custom scheme: │
│ │ {custom_scheme}://callback │
│ │ ?session_id=UUID │
│ Deep-link received │ │ │
│<──────────────────────────────────────────── │
│ │ │ │
│ 6. POST token exchange with own verifier │ │
│────────────────────>│ │ │
│ JWT tokens │ │ │
│<────────────────────│ │ │
Step-by-Step: System Browser Flow
Step 1: Mobile app generates PKCE pair (same as WebView — see Step 1).
Step 2: First call the initiate endpoint with X-Client-Type: mobile, then open the returned redirect target in the system browser.
GET /api/v1/public/idp/login/{idp_slug}?code_challenge={challenge}&code_challenge_method=S256&redirect={custom_scheme}://callback
X-Client-Type: mobile
If your mobile platform cannot attach custom headers when directly opening a browser URL, do not skip this initiate request. Opening the direct URL without X-Client-Type: mobile records the flow as web; a later mobile exchange header will fail with client_type does not match the OAuth state, while omitting the exchange header will produce a web-shaped response.
Direct Initiate URL (reference):
https://your-endurain-instance.com/api/v1/public/idp/login/{idp_slug}?code_challenge={challenge}&code_challenge_method=S256&redirect={custom_scheme}://callback
The {custom_scheme} (e.g., gadgetbridge) must be configured by the Endurain
administrator via ALLOWED_REDIRECT_SCHEMES (see Configuration).
Step 3: User completes SSO. The system browser is redirected to:
{custom_scheme}://callback?session_id={uuid}
In the current web flow, the backend first redirects to the Endurain frontend with external_redirect=true; the frontend then forwards the session_id to the configured custom scheme.
Step 4: The OS invokes the app's deep-link/intent handler with the above URL.
Extract the session_id.
Step 5: Perform token exchange using the app's own code_verifier:
POST /api/v1/public/idp/session/{session_id}/tokens
Content-Type: application/json
X-Client-Type: mobile
{
"code_verifier": "<the verifier generated in step 1>"
}
Step 6: Store and use tokens (see Step 5 and Step 6).
Redirect URL Validation Rules
| Redirect value | Allowed | Notes |
|---|---|---|
/dashboard |
✅ | Relative paths always allowed |
/settings?tab=devices |
✅ | Query strings OK in relative paths |
gadgetbridge://callback |
✅ | If gadgetbridge in ALLOWED_REDIRECT_SCHEMES |
myapp://callback |
❌ | If myapp not configured — 400 returned |
https://evil.com |
❌ | External HTTP URLs always rejected |
http://localhost |
❌ | External HTTP URLs always rejected |
/../etc/passwd |
❌ | Path traversal rejected |
//evil.com |
❌ | Protocol-relative URLs rejected |
Security Features
| Feature | Description |
|---|---|
| PKCE S256 | SHA256 challenge prevents code interception |
| One-time exchange | Tokens can only be exchanged once per session |
| 10-minute expiry | OAuth state expires after 10 minutes |
| Rate limiting | 10 token exchange requests per minute |
| Session linking | Session is cryptographically bound to OAuth state |
Configuration
Environment Variables
The following environment variables control authentication behavior:
Token Configuration
| Variable | Description | Default | Required |
|---|---|---|---|
SECRET_KEY |
Secret key for JWT signing (min 32 characters recommended) | - | Yes |
ALGORITHM |
JWT signing algorithm | HS256 |
No |
ACCESS_TOKEN_EXPIRE_MINUTES |
Access token lifetime in minutes | 15 |
No |
REFRESH_TOKEN_EXPIRE_DAYS |
Refresh token lifetime in days | 7 |
No |
Session Configuration
| Variable | Description | Default | Required |
|---|---|---|---|
SESSION_IDLE_TIMEOUT_ENABLED |
Enable session idle timeout | false |
No |
SESSION_IDLE_TIMEOUT_HOURS |
Hours of inactivity before session expires | 1 |
No |
SESSION_ABSOLUTE_TIMEOUT_HOURS |
Maximum session lifetime in hours | 24 |
No |
Security Configuration
| Variable | Description | Default | Required |
|---|---|---|---|
ENVIRONMENT |
Controls refresh cookie security for login, refresh, and OAuth/SSO token exchange. production and demo set the refresh cookie Secure flag. |
production |
No |
ALLOWED_REDIRECT_SCHEMES |
Comma-separated custom URI schemes allowed as SSO redirect targets (e.g., gadgetbridge,myapp). Empty by default — only relative paths accepted. External http/https redirects are always rejected. |
`` | No |
SSRF_ALLOWED_HOSTS |
SSRF allowlist for admin-configured outbound calls (currently OIDC discovery and JWKS fetch only). Comma-separated list of exact hostnames (case-insensitive) and/or explicit IP CIDR ranges. Supports self-hosted identity providers on private networks. Examples: auth.internal.example.com or auth.internal.example.com,10.10.0.0/24,fd00::/64. Wildcards are rejected. IPv4 prefix must be ≥ /8, IPv6 ≥ /32. Every allowlisted outbound call is logged at INFO level for audit. |
`` | No |
Cookie Configuration
For web clients, the refresh token cookie is configured with:
| Attribute | Value | Purpose |
|---|---|---|
| HttpOnly | true |
Prevents JavaScript access (XSS protection) |
| Secure | true when ENVIRONMENT=production or demo; applies consistently to login, refresh, and OAuth/SSO token exchange |
Only sent over HTTPS |
| SameSite | Strict |
Prevents CSRF attacks |
| Path | /api/v1/auth |
Sent only to auth endpoints that need the refresh token |
| Expires | 7 days (default) | Matches refresh token lifetime |
Security Scopes
Endurain uses OAuth-style scopes to control API access. Each scope controls access to specific resource groups:
Available Scopes
| Scope | Description | Access Level |
|---|---|---|
profile |
User profile information | Read/Write |
users:read |
Read user data | Read-only |
users:write |
Modify user data | Write |
gears:read |
Read gear/equipment data | Read-only |
gears:write |
Modify gear/equipment data | Write |
activities:read |
Read activity data | Read-only |
activities:write |
Create/modify activities | Write |
activities:upload |
Upload activity files (.gpx, .tcx, .fit, .gz) | Write (JWT or API key on upload endpoint) |
health:read |
Read health metrics (weight, sleep, steps) | Read-only |
health:write |
Record health metrics | Write |
health_targets:read |
Read health targets | Read-only |
health_targets:write |
Modify health targets | Write |
notifications:read |
Read notifications | Read-only |
notifications:write |
Modify notifications | Write |
sessions:read |
View active sessions | Read-only |
sessions:write |
Manage sessions | Write |
server_settings:read |
View server configuration | Read-only |
server_settings:write |
Modify server settings | Write (Admin) |
identity_providers:read |
View OAuth providers | Read-only |
identity_providers:write |
Configure OAuth providers | Write (Admin) |
Scope Usage
Scopes are automatically assigned based on user permissions and are embedded in JWT tokens. API endpoints validate required scopes before processing requests.
Common Error Responses
HTTP Status Codes
| Status Code | Description | Common Causes |
|---|---|---|
400 Bad Request |
Invalid request format | Missing required fields, invalid JSON, no pending MFA login |
401 Unauthorized |
Authentication failed | Invalid credentials, expired token, invalid MFA code |
403 Forbidden |
Access denied | Invalid client type, insufficient permissions, missing required scope |
404 Not Found |
Resource not found | Invalid session ID, user not found, endpoint doesn't exist |
429 Too Many Requests |
Rate limit exceeded | Too many login attempts, OAuth requests exceeded limit |
500 Internal Server Error |
Server error | Database connection issues, configuration errors |
Example Error Responses
Invalid Client Type:
{
"detail": "Invalid client type"
}
Expired Token:
{
"detail": "Token is expired."
}
Invalid Credentials:
{
"detail": "Unable to authenticate with provided credentials"
}
Rate Limit Exceeded:
{
"detail": "Too many requests. Please try again later."
}
Missing Required Scope:
{
"detail": "Unauthorized Access - Missing permissions: {'activities:write'}"
}
Best Practices
For Web Client Applications
- Store access and CSRF tokens in memory - Never persist in localStorage or sessionStorage
- Implement automatic token refresh - Refresh before access token expires (e.g., at 80% of lifetime)
- Handle concurrent refresh requests - Use a refresh lock pattern to prevent race conditions
- Always include required headers:
Authorization: Bearer {access_token}for all authenticated requestsX-Client-Type: webfor all requestsX-CSRF-Token: {csrf_token}for protected POST/PUT/DELETE/PATCH requests, except/auth/refreshbootstrap. Protected web writes require the header;/auth/refreshalso validates the token value when supplied and bound to the session
- Handle page reload gracefully - Call
/auth/refreshon app initialization to restore in-memory tokens - Clear tokens on logout - The httpOnly cookie is cleared by the backend
For Mobile Client Applications
- Store tokens securely:
- iOS: Keychain Services
- Android: EncryptedSharedPreferences or Android Keystore
- Use PKCE for OAuth/SSO - Required for OAuth/SSO flows
- Include required headers:
Authorization: Bearer {access_token}for all authenticated requestsX-Client-Type: mobilefor all requests
- Handle token refresh proactively - Refresh before expiration
- Implement secure token deletion on logout
For Security
- Never expose
SECRET_KEYin client code or version control - Use strong, randomly generated secrets (minimum 32 characters)
- Always use HTTPS in production environments
- Enable MFA for enhanced account security
- Monitor for token reuse - Indicates potential token theft
- Enable session idle timeout for sensitive applications
- Use appropriate scopes - Request only necessary permissions
For OAuth/SSO Integration
- Always use PKCE - Required for OAuth/SSO login
- Validate state parameter - Prevents CSRF attacks on OAuth flow
- Implement proper redirect URL validation - Prevents open redirects
- Handle provider errors gracefully with user-friendly messages
- Support account linking - Allow users to connect multiple providers
- Respect token expiry - OAuth state expires after 10 minutes
For API Key Integrations
- Store the key securely — use a secrets manager, environment variable, or encrypted config file. Never hardcode it in source code
- Use the
X-API-Keyheader rather than the?api_key=query parameter to avoid key exposure in logs - Grant only supported scopes — API keys currently accept only
activities:upload - Set an expiry date when creating keys for temporary or one-off integrations
- Rotate keys periodically — delete the old key and create a new one; update any dependent services before deleting
- Revoke immediately if a key is suspected to be compromised — revocation takes effect instantly
- Monitor
last_used_atvia the list endpoint to detect unused keys that can be cleaned up