This document explains how Time-based One-Time Passwords (TOTP) are used as a second authentication factor, how to enroll a client account for 2FA, how the login flow changes when 2FA is active, and how to disable 2FA.
Terminology: A client is the entity that authenticates against
/login(a human, a service, the CLI). An Admin record is the administrator account stored in the database — TOTP state (totp_enabled,totp_secret) is stored on theAdminrecord, but it is the client that presents TOTP codes at login time. See index.md for the full glossary.
TOTP (RFC 6238) generates a 6-digit code that changes every 30 seconds. The user's authenticator app (Google Authenticator, Authy, 1Password, Apple Passwords, etc.) and the server share a secret. Both independently compute the current code using the same algorithm — there is no network communication between the app and the server to verify. If the codes match, the user has proven they possess the shared secret.
shared_secret + current_time_window ──► HMAC-{algorithm} ──► 6-digit code
(default: SHA1; configurable per realm)
| Parameter | Default | Notes |
|---|---|---|
| Algorithm | HMAC-SHA1 | Configurable per realm: SHA1, SHA256, SHA512 |
| Code length | 6 digits | Fixed |
| Time step | 30 seconds | Configurable per realm |
| Tolerance (skew) | ±1 step (accepts previous, current, next window) | Fixed |
| Secret size | 160 bits (CSPRNG) | Fixed |
| Module | Source path | Responsibility |
|---|---|---|
totp |
src/totp/mod.rs |
Core TOTP primitives: secret generation, token validation, otpauth:// URL construction |
middleware::totp |
src/middleware/totp.rs |
TotpMiddleware — orchestrates enrollment (with realm-config lookup), verify-and-enable, disable, status check |
database::trait |
src/database/trait.rs |
Database trait with TOTP persistence method signatures |
database::impls |
src/database/impls/{sqlite,postgres,mysql}.rs |
TOTP persistence: secret storage, enabled flag, otpauth:// URL |
models::base |
src/models/base.rs |
Admin struct carrying the TOTP fields |
TOTP state is stored on the Admin record:
pub struct Admin {
pub totp_enabled: Option<bool>, // Is 2FA currently active?
pub totp_secret: Option<String>, // Base32-encoded shared secret
pub totp_auth_url: Option<String>, // otpauth:// URL stored at enable time
// …
}TotpMiddleware is a programmatic service component. Its methods are called by other server code (e.g., by the TOTP HTTP endpoints). The HTTP endpoints are registered under the /realms scope.
The Database trait provides the following TOTP persistence methods:
| Method | Description |
|---|---|
generate_totp_secret(realm, username) |
Generates a random TOTP secret using default params (Totps::new()); returns only the Base32 string. Does not use realm TOTP config; does not persist anything. Lower-level helper — prefer TotpMiddleware::handle_totp_generate for enrollment. |
enable_totp(realm, username, secret, issuer) |
Stores the verified secret, sets totp_enabled = 1, regenerates and stores totp_auth_url (using default SHA1/30 s params — see per-realm note). Targets WHERE id = username AND (userpass IS NOT NULL OR jwt IS NOT NULL). |
disable_totp(realm, username) |
Clears secret and auth URL, sets totp_enabled = 0. Same WHERE condition as enable_totp. |
get_totp_secret(realm, username) |
Returns the stored Base32 secret, or None if not set. |
is_totp_enabled(realm, username) |
Returns Some(true/false) for the user's TOTP state, or None if the user is not found. |
Before a client can use 2FA their account must be enrolled: generate a secret, scan it into their authenticator app, and verify one code to confirm they can produce valid tokens.
The enrollment flow is exposed via two HTTP endpoints under the /realms scope:
| Endpoint | Description |
|---|---|
POST /realms/{realm}/totp/generate?realm={realm} |
Generate a new TOTP secret for a user. Returns secret_base32 and otpauth_url. The secret is not stored yet. |
POST /realms/{realm}/totp/verify?realm={realm} |
Validate a one-time code against the generated secret. On success, the secret is stored and TOTP is activated for the user. |
Both endpoints require a valid session cookie from a realm administrator.
sequenceDiagram
autonumber
participant U as Client
participant App as Authenticator App
participant EA as Auth Server
participant DB as Database
U->>EA: POST /realms/{realm}/totp/generate?realm={realm}<br/>{"username":"alice"}
note over EA: totp_generate handler<br/>get_realm(realm) → auth_params.totp_params<br/>create_totp_secret(issuer, alice, realm_params)
EA->>EA: Generate 160-bit CSPRNG secret
EA->>EA: Build otpauth:// URL (with realm algorithm/step)
EA-->>U: 200 OK<br/>{"secret_base32":"JBSWY3DPEHPK3PXP",<br/> "otpauth_url":"otpauth://totp/Auth:alice?…"}
U->>App: Scan QR code (rendered from otpauth_url)
note over App: Stores secret, starts generating codes
App-->>U: "482913" (current code)
U->>EA: POST /realms/{realm}/totp/verify?realm={realm}<br/>{"username":"alice","token":"482913","secret":"JBSWY3DPEHPK3PXP"}
note over EA: totp_verify handler<br/>get_realm(realm) → auth_params.totp_params<br/>Totps::from_secret(secret, issuer, alice, realm_params)<br/>totps.validate_token("482913")
alt Token valid
EA->>DB: enable_totp(realm, alice, secret, issuer)
DB-->>EA: OK
EA-->>U: 200 OK — 2FA enabled
else Token invalid (wrong code or expired)
EA-->>U: 403 Forbidden — invalid TOTP token
end
The URL encodes everything the authenticator app needs:
otpauth://totp/{issuer}:{username}?secret={base32}&issuer={issuer}&algorithm={algorithm}&digits=6&period={step}
The algorithm and period values come from the realm's TOTP configuration (auth_params.totp_params). Example for a realm using the defaults (SHA1, 30-second step):
otpauth://totp/Auth:alice?secret=JBSWY3DPEHPK3PXP&issuer=Auth&algorithm=SHA1&digits=6&period=30
Render this as a QR code and display it to the client exactly once during enrollment. After enrollment, the raw secret must never be shown again.
When TOTP is enabled for a user, the POST /login endpoint enforces a two-step login:
- The client sends credentials as usual. If they are valid and TOTP is enabled, the server returns
TotpRequiredinstead of issuing a session cookie. - The client sends the same credentials again, this time including a
totp_code. The server validates the code and, if correct, issues the session cookie.
If TOTP is not enabled for the user, step 1 returns Authenticated directly (no change from the regular flow).
sequenceDiagram
autonumber
participant U as Client
participant App as Authenticator App
participant EA as Auth Server
participant DB as Database
U->>EA: POST /login?realm={realm}<br/>{"public_key_pem": null}
note over EA: EnsureAuth / UsernamePasswordAuth middleware<br/>validates primary credentials
EA->>DB: is_totp_enabled(realm, username)
DB-->>EA: true
alt TOTP enabled — no code provided
EA-->>U: 200 OK<br/>{"next_step":"TotpRequired","session_id":null}
U->>App: Read current TOTP code
App-->>U: "482913"
U->>EA: POST /login?realm={realm}<br/>{"totp_code":"482913"}
EA->>DB: get_totp_secret(realm, username)
DB-->>EA: "JBSWY3DPEHPK3PXP"
EA->>EA: Totps::from_secret(secret, …)<br/>totps.validate_token("482913")
alt Token valid
EA-->>U: 200 OK + Set-Cookie<br/>{"next_step":"Authenticated","session_id":"…"}
else Token invalid
EA-->>U: 401 Unauthorized — Invalid TOTP code
end
else TOTP not enabled
EA-->>U: 200 OK + Set-Cookie<br/>{"next_step":"Authenticated","session_id":"…"}
end
pub struct LoginRequest {
pub public_key_pem: Option<String>,
pub totp_code: Option<String>, // Omit on first attempt; send on second
}
pub enum AuthenticationNextStep {
ChangePassword,
TotpRequired, // Credentials valid, TOTP code still needed
Authenticated,
}An administrator can disable TOTP on a client's account via:
DELETE /realms/{realm}/totp/{username}?realm={realm}
This clears the stored secret and totp_enabled flag on the Admin record. Requires a valid session cookie from a realm administrator.
sequenceDiagram
autonumber
participant Admin as Admin
participant EA as Auth Server
participant DB as Database
Admin->>EA: DELETE /realms/{realm}/totp/{username}?realm={realm}
note over EA: totp_disable handler
EA->>DB: disable_totp(realm, username)
note over DB: UPDATE admin<br/>SET totp_enabled = 0,<br/> totp_secret = NULL,<br/> totp_auth_url = NULL<br/>WHERE id = username<br/>AND (userpass IS NOT NULL OR jwt IS NOT NULL)
DB-->>EA: OK
EA-->>Admin: 204 No Content
After this, the client logs in via primary credentials only, with no TOTP code required.
── TOTP enrollment ──
POST /realms/{realm}/totp/generate?realm={realm}
→ totp_generate handler (src/server/endpoints/totp_endpoints.rs)
└── database.get_realm(realm) → auth_params.totp_params (or defaults)
└── totp::create_totp_secret(issuer, username, totp_params)
├── Secret::generate_secret() // 160-bit CSPRNG
├── TOTP::new(algorithm, 6, 1, step, secret_bytes, issuer, username)
└── Returns (Totps { totp }, secret_base32)
└── 200 OK: { secret_base32, otpauth_url } (secret NOT yet stored)
POST /realms/{realm}/totp/verify?realm={realm}
→ totp_verify handler
└── database.get_realm(realm) → auth_params.totp_params (or defaults)
└── Totps::from_secret(secret_base32, issuer, username, totp_params)
└── totps.validate_token(token)
└── TOTP::check_current(token) // ±1 step tolerance
└── (on success) database.enable_totp(realm, username, secret_base32, issuer)
└── UPDATE admin SET totp_enabled=1, totp_secret=?, totp_auth_url=?
WHERE id=? AND (userpass IS NOT NULL OR jwt IS NOT NULL)
└── 200 OK: {"status":"ok"}
── TOTP disable ──
DELETE /realms/{realm}/totp/{username}?realm={realm}
→ totp_disable handler
└── database.disable_totp(realm, username)
└── UPDATE admin SET totp_enabled=0, totp_secret=NULL, totp_auth_url=NULL
WHERE id=? AND (userpass IS NOT NULL OR jwt IS NOT NULL)
└── 204 No Content
── Login flow with TOTP ──
POST /login?realm={realm}
→ EnsureAuth / UsernamePasswordAuth middleware
└── Validates credentials → sets AuthenticatedClient in request extensions
→ login() handler (src/server/endpoints/client_endpoints.rs)
└── database.is_totp_enabled(realm, username)
if not enabled:
└── issue_token + build_cookie + upsert_session
└── 200 OK + Set-Cookie: { "next_step": "Authenticated", "session_id": "…" }
if enabled and no totp_code in request:
└── 200 OK: { "next_step": "TotpRequired", "session_id": null }
if enabled and totp_code present:
└── database.get_totp_secret(realm, username) → stored secret
└── Totps::from_secret(secret, None, username, realm_totp_params)
└── totps.validate_token(totp_code)
if valid:
└── issue_token + build_cookie + upsert_session
└── 200 OK + Set-Cookie: { "next_step": "Authenticated", "session_id": "…" }
if invalid:
└── 401 Unauthorized
The HMAC algorithm and time step are configurable per realm. They are stored in the realm's auth_params JSON column under the totp_params key. Realms that do not set totp_params use the defaults.
auth_params.totp_params field |
Type | Default | Accepted values |
|---|---|---|---|
algorithm |
string | "SHA1" |
"SHA1", "SHA256", "SHA512" |
step |
integer (seconds) | 30 |
any positive integer |
{
"jwt_params": { … },
"username_password_params": { … },
"totp_params": {
"algorithm": "SHA256",
"step": 60
}
}When a realm sets custom TOTP params:
POST /realms/{realm}/totp/generateuses those params to build theotpauth://URL — the authenticator app will therefore use the correct algorithm and period.POST /realms/{realm}/totp/verifylooks up the same params to reconstruct theTotpsverifier, so the token is checked with the same algorithm and time window.- The stored
totp_auth_urlin theAdminrecord is regenerated byenable_totpusing default params (SHA1, 30 s) regardless of the realm's TOTP config — this is a known limitation of the currentenable_totpimplementation. The canonical, authoritative URL (reflecting the realm's actual algorithm and step) is the one returned byTotpMiddleware::handle_totp_generateat enrollment time. Callers should use that returned URL for QR-code generation, notAdmin.totp_auth_url.
Important: If you change a realm's TOTP params after users have already enrolled, their authenticator apps will continue using the old algorithm/period. All affected users must re-enroll.
The otpauth:// URL and the raw Base32 secret must:
- Only be transmitted over HTTPS
- Be displayed to the client exactly once (during enrollment QR-code display)
- Never appear in server logs — TOTP-related log lines use
trace!/debug!macros and are filtered out of production output
TOTP secrets are stored as plain Base32 strings in the database. In high-security deployments, consider encrypting the totp_secret column at rest using a KMS-managed key.
A 6-digit TOTP code has 1 000 000 possible values. With a 30-second window and ±1 skew tolerance (3 valid windows), the effective attack window is short but non-zero. Rate-limit the login endpoint to prevent automated guessing. Common threshold: 5 failed attempts per 5-minute window per username.
TOTP is time-based. The skew of ±1 (accepting the previous/current/next 30-second window) tolerates up to 60 seconds of clock drift between the server and the user's device. Ensure the server clock is synchronised via NTP.
The current implementation does not generate single-use recovery codes. In a production deployment, generate and store a set of hashed recovery codes at enrollment time so users can regain access if they lose their authenticator device.
use auth_authentication::{AuthClient, AuthClientScheme};
// 1. Authenticate as an administrator
let admin = AuthClient::new("https://auth.example.com", AuthClientScheme::UsernamePassword {
username: "admin".to_string(),
password: "admin_password".to_string(),
})?;
admin.login("_", None, None).await?;
// 2. Generate a TOTP secret for the target user
let resp = admin.generate_totp("_", "alice", None).await?;
// resp.secret_base32: Base32 secret
// resp.otpauth_url: otpauth:// URL — render this as a QR code for alice
// 3. Alice scans the QR code and provides a code from her authenticator
let token = "482913";
admin.verify_and_enable_totp("_", "alice", &resp.secret_base32, token, None).await?;
// TOTP is now enabled for alice.
// 4. To disable TOTP later:
admin.disable_totp("_", "alice").await?;