hotp
HOTP action for HMAC-based One-Time Password authentication
hotp
Validates HMAC-based One-Time Password (HOTP) codes per RFC 4226. HOTP generates counter-synchronized one-time codes using HMAC-SHA1 and a shared secret. The counter increments with each authentication attempt, making each code usable only once.
Context
Valid inside @execute blocks. Can be combined with other authentication methods like pap for two-factor authentication.
Basic Syntax
@execute {
backend {
name "USERS";
query "FIND_USER";
}
# Validate HOTP code in password field
hotp {
resync_window 10;
secret_type "hex";
}
}
Two-Factor Authentication Example
Use the filter pipeline with secret to extract the right portion of the combined <password><hotp> PAP field for each action:
@execute {
backend {
name "USERS";
query "FIND_USER";
}
# Validate the password portion (everything except the trailing 6-digit code)
pap {
secret radius.request.password | substring(0, -6);
}
# Validate the HOTP code (last 6 characters)
hotp {
secret radius.request.password | substring(-6);
resync_window 10;
}
}
Parameters
resync_window
Defines how many counter values ahead of the current counter to check. This accounts for missed authentication attempts or token button presses.
Syntax: resync_window <forward_count>;
- Single parameter only (HOTP only checks forward, never backward)
- Default:
10 - Valid range: 0-65535
Example: resync_window 10; checks the current counter and the next 10 counter values.
A larger window increases tolerance for missed authentications but reduces security (more codes are valid simultaneously). A value of 10-20 is typical for hardware tokens.
secret_type
Specifies the encoding format of the shared secret retrieved from the backend:
"hex"- Hexadecimal encoding (default)"base32"- Base32 encoding (RFC 4648)"auto"- Automatically detects hex or base32
Default: "hex"
The secret must be set via hmac-otp.secret in the backend mapping.
min_secret_bits
Enforces a minimum secret length in bits. If the decoded secret is shorter than this value, authentication is rejected with a warning logged and counted.
Syntax: min_secret_bits <bits>;
- Default: 128 (as required by RFC 4226)
- Valid range: 0-65535
- Common values: 128 (16 bytes), 160 (20 bytes, recommended by RFC 4226)
Example: min_secret_bits 160; requires at least a 20-byte (160-bit) secret.
RFC 4226 states: "The length of the shared secret MUST be at least 128 bits. This document RECOMMENDs a shared secret length of 160 bits."
To disable the minimum check (not recommended), use min_secret_bits 0;.
secret
Expression that produces the OTP value the action will validate. Combine it with the filter pipeline (for example substring()) to derive the OTP from any context attribute. This is the recommended way to point the action at the right value.
Example, extracting the trailing 6 digits from a combined <password><hotp> PAP field:
hotp {
secret radius.request.password | substring(-6);
resync_window 10;
}
When secret is set, its value replaces the protocol PAP response that the action would otherwise read.
range
Slices the PAP response by character offsets before validation. Prefer secret with the filter pipeline for new configurations; range is limited to a single contiguous slice and remains supported mainly for backward compatibility.
Syntax: range <start> <end> [exclusive]
- Negative indices count from the end (-6 means 6 characters from the end)
exclusivekeyword excludes the specified range (extracts everything except the range)- Indices are 0-based
# Extract the last 6 characters as the HOTP code
hotp {
range -6 0;
}
Result
The hotp action produces the following pipeline results:
- Accept: The submitted HOTP code matches a valid code within the configured counter window.
- Reject: Authentication failed. This occurs when:
- The shared secret is shorter than
min_secret_bitsrequires. - No authentication response is present in the request.
- The authentication response is too short for the configured
range. - The OTP digit count does not match the expected number of digits.
- The OTP contains non-numeric characters.
- The OTP code does not match any valid code in the counter window.
- The shared secret is shorter than
Backend Mapping
The backend must populate HOTP-related context variables:
backends {
sqlite "USERS" {
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; # Required
hmac-otp.counter = hotp_counter; # Required
hmac-otp.digits = hotp_digits; # Optional (default: 6)
}
}
}
}
Required context variables:
hmac-otp.secret- Shared secret (hex or base32 encoded)hmac-otp.counter- Current counter value
Optional context variables:
hmac-otp.digits- Number of digits in HOTP code (default: 6, typically 6-8)
Counter Persistence
Critical: After successful HOTP authentication, you must persist the updated counter to prevent replay attacks and allow continued authentication:
backends {
sqlite "USERS" {
# ... FIND_USER query ...
query "UPDATE_HOTP_COUNTER" {
statement "UPDATE users SET hotp_counter = ? WHERE username = ?";
bindings {
hmac-otp.counter; # Updated counter after successful auth
user.username;
}
}
}
}
aaa {
policy "DEFAULT" {
handler "AUTHENTICATION" {
@execute {
backend {
name "USERS";
query "FIND_USER";
}
hotp {
resync_window 10;
}
}
@final-execute {
# Save updated counter after successful authentication
backend {
name "USERS";
query "UPDATE_HOTP_COUNTER";
}
}
}
}
}