TOTP and WebAuthn
Time-based one-time passwords and WebAuthn for second-factor authentication
Overview
Olympus supports TOTP (Time-based One-Time Password) as a second authentication factor, implemented via Kratos's native totp self-service strategy. Users enroll an authenticator app (Google Authenticator, Authy, 1Password, Bitwarden, and any RFC 6238-compatible app), then provide a 6-digit code at every login after the password step.
All TOTP secret generation, code validation, and backup code management are handled by Kratos. Hera provides the UI surfaces. No custom TOTP cryptography is implemented in Hera.
How It Works
TOTP in Olympus has two distinct flows: enrollment (done once in account settings) and the login challenge (triggered on every subsequent login).
Enrollment Flow
Account Settings → Security → Authenticator App
│
▼
Hera initiates Kratos settings flow (GET /self-service/settings/browser)
│
▼
Kratos returns flow with TOTP nodes:
- totp_secret_key (plaintext secret for manual entry)
- totp_qr (otpauth:// URI for QR code rendering)
│
▼
Hera renders:
- QR code (SVG, client-side rendering, no external calls)
- Manual secret key (read-only, autocomplete="off")
- 6-digit code input (autocomplete="one-time-code")
- Submit button
│
▼
User scans QR or enters secret manually into authenticator app
User enters 6-digit verification code and submits
│
▼
Hera POSTs to Kratos: { method: "totp", totp_code: "123456" }
│
├─ Invalid code → inline error, QR remains visible, no page reload
│
└─ Valid code → Kratos marks TOTP credential as active
→ Kratos generates backup codes (lookup_secret strategy)
→ Hera extracts backup codes from settings flow response
→ Hera renders backup codes (displayed once, see below)Login Challenge Flow
User completes password step (AAL1)
│
▼
Kratos detects TOTP enrolled + AAL2 required
│
▼
Kratos redirects to /login?flow=<id> at AAL2 step
│
▼
Hera detects AAL2 step in flow response, renders TOTP challenge screen:
- "Enter your authenticator code" heading
- 6-digit numeric input (auto-focused, inputmode="numeric", pattern="[0-9]*")
- Submit button
- "Use a backup code instead" link
│
├─ Invalid code → inline error with remaining attempts, input cleared and re-focused
│ (5 consecutive failures invalidate the login session)
│
└─ Valid code → Kratos validates, upgrades session to AAL2
→ Redirect to application callback URLBackup Codes
Backup codes are generated by Kratos's lookup_secret strategy at the moment of successful TOTP enrollment. They are returned once in the settings flow response and never stored by Hera.
Behavior:
- Backup codes are displayed immediately after enrollment, with "Download as .txt" and "Copy all" buttons
- A confirmation checkbox ("I have saved these backup codes") must be checked before the user can proceed, the Continue button is disabled until acknowledged
- After the user clicks Continue, the codes are cleared from React state
- Navigating back to settings does NOT re-display the codes, the UI shows "Backup codes generated. Download them now to save." with no way to reveal them again
- Each backup code is single-use, Kratos invalidates it after use
- Using a backup code at login follows the same challenge screen (click "Use a backup code instead")
If codes are lost: The user must contact an admin to remove their TOTP credential and re-enroll. There is no self-service recovery path once backup codes are lost and the authenticator app is unavailable.
API Reference
TOTP enrollment and challenge use Kratos self-service flows. Hera calls these endpoints server-side using the user's session cookie.
Enrollment
| Step | HTTP Call | Description |
|---|---|---|
| Initiate | GET /self-service/settings/browser | Creates a settings flow; returns flow ID and TOTP nodes |
| Submit code | POST /self-service/settings body: { method: "totp", totp_code: "123456" } | Validates the 6-digit code; activates TOTP credential |
Login Challenge
| Step | HTTP Call | Description |
|---|---|---|
| Detect AAL2 | Flow returned from GET /self-service/login/flows?id=<id> includes TOTP node | Hera renders challenge screen |
| Submit TOTP code | POST /self-service/login body: { totp_code: "123456" } | Validates code, upgrades session to AAL2 |
| Submit backup code | POST /self-service/login body: { lookup_secret: "<code>" } | Validates backup code, marks it as used, upgrades session |
Attempt Limit
Kratos does not natively count TOTP-specific login attempts. Hera enforces a 5-attempt limit using a server-side cookie scoped to the current login flow:
- Cookie key:
totp_attempts_{flow_id} - Attributes:
httpOnly: true,sameSite: lax, TTL matches Kratos login flow lifespan (10 minutes) - After 5 consecutive failures, Hera invalidates the login flow and redirects to the login start page with a message
The maximum attempt count defaults to 5 and is configurable via the TOTP_MAX_ATTEMPTS environment variable (future: migrated to SDK setting mfa.totp_max_attempts). When the SDK setting is present, it takes priority over the environment variable.
Note: The application-layer attempt cookie is a defense-in-depth measure. The primary brute force protection is the reverse proxy (Caddy) rate limiting on the TOTP submission endpoint. The cookie can be cleared by a user with browser DevTools access, this does not bypass the reverse proxy rate limit.
Configuration
Kratos (platform/dev/ciam-kratos/kratos.yml)
TOTP is enabled in the Kratos config with the Olympus issuer name:
selfservice:
methods:
totp:
config:
issuer: Olympus # Appears as the account name in authenticator apps
enabled: true
lookup_secret:
enabled: true # Backup codes, must remain enabledIssuer name: The issuer value appears as the account label in the user's authenticator app (e.g., "Olympus:alice@example.com"). This is set at enrollment time and embedded in the otpauth:// URI. Changing the issuer in Kratos config does not affect existing enrolled users, their authenticator app continues to show the label from the original enrollment. Only new enrollments pick up the updated issuer.
Clock skew: Kratos accepts TOTP codes from ±30 seconds of the current time window by default (RFC 6238 standard). Do not implement custom clock skew tolerance in Hera.
Environment Variables (Hera)
| Variable | Default | Description |
|---|---|---|
TOTP_MAX_ATTEMPTS | 5 | Maximum TOTP attempts before login flow invalidation |
Edge Cases
Abandoned enrollment (user closes page before entering verification code)
Kratos's settings flow expires after its configured lifespan (10 minutes). If the user closes the page without completing verification, the pending TOTP enrollment expires automatically. No partial TOTP device is left active. Hera does not need custom cleanup logic.
Expired login flow during TOTP challenge
If the Kratos login flow expires (10-minute TTL) while the user is on the TOTP challenge screen, the next submission returns 410 Gone. Hera detects this and redirects to the login start page with a clear message: "Your login session expired. Please log in again."
Backup code used at login
The "Use a backup code instead" link reveals an additional text input inline, no page reload. The TOTP challenge input is hidden. Submitting a valid backup code completes the AAL2 challenge and invalidates the code. The user is warned to generate new backup codes if they are running low.
QR code rendering failure (JavaScript disabled)
The QR code requires JavaScript for client-side SVG rendering. If JavaScript is disabled, the QR code does not render. The manual entry key is always visible as a fallback, users can enter the secret manually in their authenticator app.
Security Considerations
- The
otpauth://URI (containing the TOTP secret) is rendered as an inline SVG by a pinned QR library, it is never sent to an external image service. - The manual entry key is displayed as a read-only
<code>element withautocomplete="off", browsers do not save it in form history. - Backup codes are held in React state and never stored in
localStorageorsessionStorage. They are cleared from state when the user proceeds past the backup codes screen. - Hera does not persist any TOTP enrollment state in the browser beyond the Kratos flow ID (stored in the Kratos-managed session cookie). On page reload during enrollment, a fresh settings flow is initiated.
- The backup code confirmation gate (checkbox) ensures users acknowledge the codes before proceeding. The Continue button is disabled until checked.
- Production Kratos config must have
log.leak_sensitive_values: false, the dev config enables this flag for debugging but it must never be set in production. TOTP secrets would appear in logs if this flag were enabled in production.
Related Issues
- platform#13, TOTP config fix: issuer rename from "Kratos" to "Olympus"
- hera#28, TOTP enrollment UI (QR code + verification)
- hera#29, TOTP login challenge screen
- platform#14, MFA policy admin panel (admin toggle for TOTP requirement)
Last updated: 2026-04-01 (Technical Writer, hera#28, hera#29, platform#13)