TOTP/HOTP Authentication
Time-based and HMAC-based One-Time Password authentication
Radiator Server supports TOTP (Time-based One-Time Password) and HOTP (HMAC-based One-Time Password) authentication methods, commonly used for two-factor authentication (2FA) and multi-factor authentication (MFA) scenarios.
Overview
- TOTP (RFC 6238): Generates time-based one-time passwords that change every N seconds (typically 30 seconds)
- HOTP (RFC 4226): Generates counter-based one-time passwords that change after each successful authentication
Both methods use HMAC-SHA1 with a shared secret to generate numeric codes of configurable length (typically 6-8 digits).
Always use TOTP/HOTP as part of a multi-part authentication scheme. Alone it is not sufficient security due to the amount of digits passed. Usually TOTP is preferred over HOTP if system clocks can be kept in synchronization.
Secret Encoding
Important: TOTP/HOTP secrets MUST be provided as either:
- Base32-encoded strings (RFC 4648) - standard format used by authenticator apps
- Hex-encoded strings with
0xprefix - convenient for binary secrets
The base32 encoding is standard for TOTP/HOTP implementations and is compatible with common authenticator apps like Google Authenticator, Microsoft Authenticator, and Authy.
Secret Format Detection
By default, the server uses hexadecimal format for secret decoding (with automatic 0x prefix stripping if present). The radiator-client tool uses auto-detection (secrets with 0x prefix are hex, otherwise base32).
You can configure the secret format in the totp or hotp action block:
authentication {
backend {
name "USERS";
query "LOAD_USER";
}
# Configure secret format in the action
totp {
secret_type "base32"; # or "hex" or "auto"
resync_window 1;
}
}
Valid secret type values:
"hex"- Hexadecimal format (default, strips0xprefix if present)"auto"- Auto-detect:0xprefix -> hex, otherwise base32"base32"- Base32 format (RFC 4648)
Note: Secrets are stored as raw strings during backend mapping and decoded lazily during authentication using the configured secret_type.
Example Secret Formats
Hex (160-bit, preferred): 0x3132333435363738393031323334353637383930
Hex (160-bit, no prefix): 3132333435363738393031323334353637383930
Base32 (80-bit): JBSWY3DPEHPK3PXP
Base32 (160-bit): GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
Invalid: mysecretpassword (plain text not supported)
Note: The hex and base32 examples above represent the same binary value ("12345678901234567890" in ASCII).
With the default hex format:
- Secrets with
0xor0Xprefix: prefix is stripped before decoding - Secrets without prefix: decoded as hex directly
- To use base32, configure
secret_type "base32"in the totp/hotp action block
Base32 uses characters: A-Z and 2-7 (case-insensitive, no padding).
Hex uses: 0-9 and a-f/A-F (case-insensitive).
radiator-client Secret Format
The radiator-client tool uses auto-detection for all secret inputs:
- Secrets with
0xprefix are decoded as hexadecimal - All other secrets are decoded as base32
This matches the server's default behavior, ensuring compatibility.
Secret Length Recommendations
RFC 4226 recommends 160-bit (20-byte) secrets, which encode to 32 base32 characters or 40 hex digits:
- 160 bits (32 chars base32, 40 hex): RFC recommended, maximum security for SHA-1 HMAC
- 128 bits (26 chars base32, 32 hex): Strong security, modern standard
- 80 bits (16 chars base32, 20 hex): Google Authenticator default, minimum recommended
The radiator-client --totp-generate-only and --hotp-generate-only commands generate 160-bit secrets following the RFC recommendation.
Configuration
TOTP Configuration
Basic TOTP authentication validates the entire password field as a TOTP code:
authentication {
backend {
name "USERS";
query "LOAD_USER";
}
# Validate TOTP in password field
totp {
# secret_type "hex"; # Default
# timestep 30; # Default
# resync_window 1 0; # Default; Check 1 timestep to the past and none to the future
}
}
Backend mapping for TOTP:
mapping {
hmac-otp.secret = doc | jsonpath("$.users[?(@.username == '%{aaa.identity}')].totp_secret");
hmac-otp.digits = doc | jsonpath("$.users[?(@.username == '%{aaa.identity}')].totp_digits");
hmac-otp.timestep = doc | jsonpath("$.users[?(@.username == '%{aaa.identity}')].totp_timestep");
}
Configuration parameters:
hmac-otp.secret: Shared secret in hex (default) or base32 format (required)hmac-otp.digits: Number of digits in the OTP code (default: 6, typically 6-8)hmac-otp.timestep: Time window in seconds (default: 30)hmac-otp.timestep.origin: Unix timestamp origin (default: 0, rarely needs to be changed)
Action configuration (in totp block):
secret_type: Secret format - "hex" (default), "base32", or "auto"resync_window: Timestep tolerance for clock drift- Single value:
resync_window 1;- checks 1 timestep before and 1 after current (symmetric window) - Two values:
resync_window 2 1;- checks 2 timesteps before and 1 after current (asymmetric window) - With default 30-second timestep:
resync_window 1allows +/-30 seconds of drift - Default:
1 0(check 1 timestep before, 0 after current time - with default 30s timestep this allows codes from 30s in the past)
- Single value:
HOTP Configuration
Basic HOTP authentication:
authentication {
backend {
name "USERS";
query "LOAD_USER";
}
# Validate HOTP in password field
hotp {
# secret_type "hex"; # Default: hex format
# resync_window 10; # Default: check current counter + 10 ahead
}
}
Backend mapping for HOTP:
mapping {
hmac-otp.secret = hotp_secret;
hmac-otp.counter = hotp_counter;
hmac-otp.digits = hotp_digits;
}
Configuration parameters:
hmac-otp.secret: Shared secret in hex (default) or base32 format (required)hmac-otp.counter: Current counter value (required for HOTP)hmac-otp.digits: Number of digits in the OTP code (default: 6)
Action configuration (in hotp block):
secret_type: Secret format - "hex" (default), "base32", or "auto"resync_window: Counter lookahead window (forward-only checking)- Single value:
resync_window 10;- checks current counter + 10 ahead. 10 is also the defaul value.
- Single value:
Important: After successful HOTP authentication, the counter must be persisted so that users can continue logging in and so that replay attacks are mitigated.
Counter Persistence Example
authentication {
backend {
name "HOTP_USERS";
query "FIND_USER";
}
hotp {
resync_window 10;
}
}
post-authentication {
# Update counter in database after successful authentication
backend {
name "HOTP_USERS";
query "UPDATE_HOTP_COUNTER";
}
}
SQLite backend query example:
query "UPDATE_HOTP_COUNTER" {
statement "UPDATE users SET hotp_counter = ? WHERE username = ?";
bindings {
hmac-otp.counter; # Updated counter value
user.username;
}
}
Two-Factor Authentication (2FA)
Recommended: Password + TOTP with Replay Protection
The recommended 2FA approach combines password hashing with TOTP and implements replay protection by tracking timesteps. This prevents password database compromise and TOTP replay attacks. See the Password Hashing article for details on secure password storage using Argon2 and other algorithms.
backends {
sqlite "TOTP_USERS" {
filename "users.sqlite";
# Read user data including password, TOTP configuration and last used timestep
query "FIND_USER" {
statement "SELECT username, password, totp_secret, totp_digits, totp_timestep, totp_last_timestep FROM users WHERE username = ?";
bindings {
aaa.identity;
}
mapping {
user.username = username;
user.password = password;
hmac-otp.secret = totp_secret;
hmac-otp.digits = totp_digits;
hmac-otp.timestep = totp_timestep;
hmac-otp.timestep.last = totp_last_timestep;
}
}
# Store the timestep of the successful authentication for replay protection
query "UPDATE_TOTP_TIMESTEP" {
statement "UPDATE users SET totp_last_timestep = ? WHERE username = ?";
bindings {
hmac-otp.timestep.last;
user.username;
}
}
}
}
aaa {
policy "DEFAULT" {
handler "AUTHENTICATION" {
authentication {
backend {
name "TOTP_USERS";
query "FIND_USER";
}
# Validate password (all but last 8 characters)
pap {
range -8 0 exclusive;
}
# Validate TOTP (last 8 characters)
totp {
range -8 0;
secret_type "hex";
resync_window 1;
}
}
post-authentication {
# Update the last used timestep to prevent replay attacks
backend {
name "TOTP_USERS";
query "UPDATE_TOTP_TIMESTEP";
}
}
}
}
}
SQLite schema with password hashing and replay protection:
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
totp_secret TEXT NOT NULL,
totp_digits INTEGER DEFAULT 8,
totp_timestep INTEGER DEFAULT 30,
totp_last_timestep INTEGER DEFAULT NULL
);
-- Password uses argon2 hash (see Password Hashing article), TOTP secret in hex format
-- totp_last_timestep can be NULL initially; replay protection activates after first successful auth
-- totp_origin defaults to 0 (Unix epoch) in server, no need to store it
INSERT INTO users (username, password, totp_secret, totp_digits, totp_timestep)
VALUES ('alice', '{argon2}$argon2id$v=19$m=16384,t=2,p=1$YzZweHZaQmp5WWlMZjhaS3AzcGJBQT09$TtLQfZS6umGIXA5JeIoEEkJgnJ5JcLbBEOi0hYcmTho', '3132333435363738393031323334353637383930', 8, 30);
Testing with radiator-client:
# Generate TOTP code and combine with password
TOTP_CODE=$(radiator-client --totp-secret "0x3132333435363738393031323334353637383930" --totp-digits 8 --totp-generate-only)
# Authenticate with password + TOTP
radiator-client \
--server 127.0.0.1 \
--port 1812 \
--secret mysecret \
--user alice \
--password "AliceSecure789${TOTP_CODE}" \
--type auth
# Immediate replay will be rejected with "Old TOTP replayed" error
radiator-client \
--server 127.0.0.1 \
--port 1812 \
--secret mysecret \
--user alice \
--password "AliceSecure789${TOTP_CODE}" \
--type auth
Key Security Features:
- Password Hashing: Passwords stored with Argon2 or other secure algorithms (see Password Hashing)
- Replay Protection: Tracking
totp_last_timestepprevents reuse of codes from the same or earlier timesteps - Time-Sync Tolerance:
resync_window 1allows +/-1 timestep (with default 30s timestep = +/-30s) while maintaining security - 8-Digit Codes: Increased entropy compared to standard 6-digit codes
How Replay Protection Works:
- After successful TOTP validation at timestep T, the database stores T
- Any subsequent authentication with a timestep <= T is rejected as "Old TOTP replayed"
- This prevents replay attacks within the same time window and from past windows
- Users can authenticate again with a code from a future time window
Basic Password + TOTP/HOTP
Use the range parameter to split the password field into password and OTP components:
authentication {
backend {
name "USERS";
query "LOAD_USER";
}
# Validate password (all but last 6 characters)
pap {
range -6 0 exclusive;
}
# Validate HOTP (last 6 characters)
hotp {
range -6 0;
resync_window 10;
}
}
Range syntax:
range -6 0: Extract last 6 characters (inclusive)range -6 0 exclusive: Extract all but last 6 characters- Negative indices count from the end of the string
Example with 8-digit OTP:
pap {
range -8 0 exclusive; # Password part
}
hotp {
range -8 0; # 8-digit OTP part
}
Testing with radiator-client
The radiator-client tool provides built-in support for TOTP/HOTP testing.
Generate TOTP Code
# Generate current TOTP code
radiator-client --totp-secret JBSWY3DPEHPK3PXP --totp-generate-only
# Generate with custom digits
radiator-client --totp-secret JBSWY3DPEHPK3PXP --totp-digits 8 --totp-generate-only
# Generate pseudo random secret
radiator-client --totp-generate-only
Generate HOTP Code
# Generate HOTP code for counter 0
radiator-client --hotp-secret JBSWY3DPEHPK3PXP --hotp-counter 0 --hotp-generate-only
# Generate with custom digits
radiator-client --hotp-secret JBSWY3DPEHPK3PXP --hotp-counter 5 --hotp-digits 8 --hotp-generate-only
# Generate random secret (no counter needed)
radiator-client --hotp-generate-only
Test TOTP Authentication
# TOTP only (password field contains TOTP code)
radiator-client \
--server 127.0.0.1 \
--port 1812 \
--secret mysecret \
--user alice \
--totp-secret JBSWY3DPEHPK3PXP \
--type auth
Test HOTP Authentication
# HOTP only
radiator-client \
--server 127.0.0.1 \
--port 1812 \
--secret mysecret \
--user testuser \
--hotp-secret OHW56UKCF6BMTXCDJ2TKPF23AO4ZMLML \
--hotp-counter 0 \
--hotp-digits 6 \
--type auth
Test 2FA (Password + TOTP/HOTP)
The radiator-client automatically concatenates password and OTP when both are provided:
# Password + TOTP
radiator-client \
--server 127.0.0.1 \
--port 1812 \
--secret mysecret \
--user alice \
--password alicepass123 \
--totp-secret JBSWY3DPEHPK3PXP \
--type auth
# Password + HOTP
radiator-client \
--server 127.0.0.1 \
--port 1812 \
--secret mysecret \
--user bob \
--password b0bSecure! \
--hotp-secret YLH3I7GKX6PKELIFE6PWGC4FVNB5YPPC \
--hotp-counter 0 \
--hotp-digits 6 \
--type auth
Common Patterns
HOTP with SQLite Backend and Counter Persistence
backends {
sqlite "HOTP_USERS" {
filename "users.db";
query "FIND_USER" {
statement "SELECT username, hotp_secret, hotp_counter, hotp_digits FROM users WHERE username = ?";
bindings {
aaa.identity;
}
mapping {
user.username = username;
hmac-otp.secret = hotp_secret;
hmac-otp.counter = hotp_counter;
hmac-otp.digits = hotp_digits;
}
}
query "UPDATE_HOTP_COUNTER" {
statement "UPDATE users SET hotp_counter = ? WHERE username = ?";
bindings {
hmac-otp.counter;
user.username;
}
}
}
}
aaa {
policy "DEFAULT" {
handler "AUTHENTICATION" {
authentication {
backend {
name "HOTP_USERS";
query "FIND_USER";
}
hotp {
resync_window 10;
}
}
post-authentication {
backend {
name "HOTP_USERS";
query "UPDATE_HOTP_COUNTER";
}
}
}
}
}
Example SQLite schema:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
hotp_secret TEXT NOT NULL,
hotp_counter INTEGER DEFAULT 0,
hotp_digits INTEGER DEFAULT 6
);
-- Using hex secrets (default format, no secret_type configuration needed)
INSERT INTO users (username, hotp_secret, hotp_counter, hotp_digits)
VALUES ('alice', '9bb65652855a09d0ae35c876e6c38b35fa34a0cd', 0, 6);
2FA with Password + HOTP
backends {
sqlite "USERS_2FA" {
filename "users.db";
query "FIND_USER" {
statement "SELECT username, password, hotp_secret, hotp_counter, hotp_digits FROM users WHERE username = ?";
bindings {
aaa.identity;
}
mapping {
user.username = username;
user.password = password;
hmac-otp.secret = hotp_secret;
hmac-otp.counter = hotp_counter;
hmac-otp.digits = hotp_digits;
}
}
query "UPDATE_HOTP_COUNTER" {
statement "UPDATE users SET hotp_counter = ? WHERE username = ?";
bindings {
hmac-otp.counter;
user.username;
}
}
}
}
aaa {
policy "DEFAULT" {
handler "AUTHENTICATION" {
authentication {
backend {
name "USERS_2FA";
query "FIND_USER";
}
# Validate password (all but last 6 characters)
pap {
range -6 0 exclusive;
}
# Validate HOTP (last 6 characters)
hotp {
range -6 0;
resync_window 10;
}
}
post-authentication {
backend {
name "USERS_2FA";
query "UPDATE_HOTP_COUNTER";
}
}
}
}
}
Example SQLite schema for 2FA:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
hotp_secret TEXT NOT NULL,
hotp_counter INTEGER DEFAULT 0,
hotp_digits INTEGER DEFAULT 6
);
-- Using hex secrets (default format, no secret_type configuration needed)
INSERT INTO users (username, password, hotp_secret, hotp_counter, hotp_digits)
VALUES ('alice', 'alicepass123', '9bb65652855a09d0ae35c876e6c38b35fa34a0cd', 0, 6);
Security Considerations
Secret Storage
- Store base32-encoded secrets securely in your backend database
- Use encrypted database columns for sensitive data
- Never log or expose secrets in plain text
Clock Synchronization (TOTP)
- Ensure server clocks are synchronized using NTP
- Use the
resync_windowparameter to allow reasonable clock drift- Single parameter creates symmetric window:
resync_window 1;checks 1 timestep before and 1 after - Two parameters create asymmetric window:
resync_window 2 1;checks 2 timesteps before, 1 after - With default 30-second timestep:
resync_window 1;allows +/-30 seconds - With default 30-second timestep:
resync_window 2;allows +/-60 seconds - Default:
1 0checks 1 timestep before, 0 after (with default 30s timestep, accepts codes from 30s in the past)
- Single parameter creates symmetric window:
- Monitor for excessive clock drift which may indicate attacks
Replay Protection
TOTP Replay Protection:
TOTP with timestep tracking provides strong replay protection when configured correctly:
- Track
hmac-otp.timestep.lastin the backend database - After a successful authentication store the timestep.last to allow for timestep and resync_window parameters to work.
Example UPDATE query:
query "UPDATE_TOTP_TIMESTEP" {
statement "UPDATE users SET totp_last_timestep = ? WHERE username = ?";
bindings {
hmac-otp.timestep.last;
user.username;
}
}
The server validates that the authenticated timestep is strictly greater than the last stored timestep, preventing any replay attempts.
HOTP Replay Protection:
HOTP counter persistence prevents replay attacks - each counter value can only be used once.
Digit Count Validation
The server strictly validates that OTP codes have exactly the expected number of digits. For example, a 9-digit code will be rejected if the configuration expects 8 digits, even if the numeric value would otherwise match.
Troubleshooting
Authentication Fails with Valid TOTP
- Check clock synchronization on both client and server
- Verify
resync_windowis sufficient for clock drift (default is1 0which accepts codes from 30s in the past; useresync_window 1;for symmetric +/-30s, orresync_window 2;for +/-60s) - Confirm secret format matches configuration (hex by default, or base32 if
secret_type "base32"is set) - Check
timestepmatches authenticator app settings (usually 30 seconds) - Verify
digitsmatches authenticator app configuration
Authentication Fails with Valid HOTP
- Verify counter value is correct and up-to-date
- Increase
resync_windowif counter may be ahead (default is10; use larger value likeresync_window 20;if needed) - Confirm secret format matches configuration (hex by default, or base32 if
secret_type "base32"is set) - Check that counter is being persisted after successful authentication
- Verify
digitsmatches expected OTP length
Digit Count Mismatch
Error: "Invalid TOTP/HOTP digit count: expected X digits, got Y digits"
- Ensure client generates OTP with correct number of digits
- Check
hmac-otp.digitsconfiguration matches authenticator app - Verify leading zeros are preserved (e.g., "012345" not "12345")
2FA Password Splitting Issues
- Verify
rangeparameters are correct for your OTP length - Check that password + OTP concatenation matches expected format
- Use
radiator-clientwith both--passwordand--totp-secret/--hotp-secretfor testing
Related Documentation
- Password Hashing - Secure password storage using Argon2 and other algorithms
- Execution Context - Available context variables including HMAC-OTP variables
- Backend Configuration - Loading user data and secrets from databases
- Pipeline Directives - Using authentication actions and flow control