YubiKey Authentication
Yubico OTP validation with Radiator - cloud and offline paths
- What is YubiKey?
- Cloud Validation (yubikey HTTP backend)
- Offline Validation (yubikey action)
- Choosing a Validation Backend
- Cloud Validation Configuration
- Backend Configuration
- Choosing a timeout value
- AAA Policy
- Password + OTP (Two-Factor)
- Single-key jsonfile example
- Multi-key SQL example
- Using a Self-Hosted Validation Server
- Offline Validation Configuration
- Backend Schema
- Backend Configuration
- AAA Policy
- Password + OTP (Two-Factor)
- Related Documentation
What is YubiKey?
A YubiKey is a hardware security token manufactured by Yubico. When the user presses the button, the token generates a one-time password (OTP) and types it as keyboard input. Each OTP is unique and expires after a single use, which prevents replay attacks even if the OTP is intercepted in transit.
The Yubico OTP is a fixed 44-character modhex string carrying a public UID and an AES-128 encrypted payload. See YubiKey OTP Filter for the full layout and the components that can be extracted from it.
Radiator supports two validation paths explained below.
Cloud Validation (yubikey HTTP backend)
The yubikey HTTP backend forwards the OTP to a Yubico OTP validation server, such as YubiCloud at api.yubico.com/wsapi/2.0/verify or a self-hosted validation server such as YK-VAL. Radiator constructs a signed HMAC-SHA1 request, sends it over HTTPS, and verifies the HMAC-signed response.
This backend uses the Yubico OTP validation protocol. Do not point it directly at a YK-KSM decryption endpoint; that is a different protocol.
The validation server is only responsible for YubiKey OTP validity: it confirms that the OTP is well-formed, belongs to a known token for that validation service, and has not been replayed. Radiator remains the AAA decision-maker. It is still responsible for user lookup, account state, authorization policy, and the final accept or reject decision.
Flow:
- User sends RADIUS Access-Request with the OTP as the PAP password.
- Radiator sends a signed GET request to the validation server.
- The server verifies the OTP, checks its replay database, and returns a signed response.
- Radiator verifies the response HMAC and then continues the surrounding AAA logic.
This path requires no local key storage and offloads replay tracking to the Yubico cloud infrastructure.
When users submit <password><otp> in one PAP field, set secret on the backend action with the yubikey() filter to extract only the OTP before Radiator sends the validation request.
Offline Validation (yubikey action)
The yubikey pipeline action decrypts and validates the OTP locally inside Radiator without any network call. This requires the AES-128 key for the token to be stored in a Radiator-accessible backend (database, file, etc.).
Flow:
- User sends RADIUS Access-Request with the OTP as the PAP password.
- A backend query loads
yubikey.secret(and optionally public UID and replay counters) into the authentication context. - The
yubikeyaction decrypts the OTP, verifies CRC16, checks the private UID, and compares usage and session counters against stored values. - On success, updated counters are persisted to the backend.
This path gives the fastest response time and works without internet access.
The same single-key and multi-key models described for cloud validation apply here:
- Single-key model: load the one expected YubiKey for the user with the user record.
- Multi-key model: bind the backend query on both
aaa.identityand the presented YubiKey public UID so the lookup selects the row for the presented token directly.
Choosing a Validation Backend
For production deployments, use YubiCloud (api.yubico.com/wsapi/2.0/verify) or a self-hosted YubiKey validation server such as YK-VAL. YK-KSM can provide key storage and decryption behind the validation server, but it is not itself the validation endpoint that Radiator calls. These validation services provide token lifecycle management - provisioning, revocation, and key rotation - that Radiator does not include.
Radiator's offline yubikey action performs OTP decryption and replay counter checks but does not provide tools for provisioning tokens, revoking compromised keys, or managing token expiration. It is suitable for small deployments and testing where keys are managed manually.
| Deployment | Recommended backend | Notes |
|---|---|---|
| Production | YubiCloud or self-hosted YK-VAL | Full lifecycle management; YK-KSM can be used behind YK-VAL |
| Small / lab | Offline yubikey action with local database | Manual key management |
Cloud Validation Configuration
Backend Configuration
backends {
yubikey "YUBIKEY_CLOUD" {
url "https://api.yubico.com/wsapi/2.0/verify";
username "12345"; # Yubico client ID
secret "base64encodedsecret="; # Yubico API key (base64)
timeout 5s;
# connections 64; # optional, default 100
}
}
| Field | Required | Description |
|---|---|---|
url | Yes | Base URL of the validation server |
username | Yes | Client ID issued by Yubico (or your validation server) |
secret | Yes | Base64-encoded API key for HMAC request signing |
timeout | Yes | Request timeout (e.g. 5s, 500ms) |
connections | No | Max concurrent HTTP connections (default: 100) |
tls | No | Custom TLS configuration block |
Choosing a timeout value
Cloud validation goes over the public internet and is subject to variable latency. If the Yubico server does not respond within the configured timeout, Radiator treats the request as a backend error and the pipeline stops. Wrap the backend call in a try action to handle timeout errors explicitly:
try {
backend {
name "YUBIKEY_CLOUD";
}
} catch {
reject "YubiKey cloud validation failed: %{aaa.caught_error}";
}
Recommended starting values:
- Yubico cloud (
api.yubico.com):5s- allows for typical internet round-trips and brief Yubico API delays. - Self-hosted validation server on the same LAN:
1s-2s- lower latency expected; a tighter timeout detects hung servers faster.
AAA Policy
No backend query is needed to validate the OTP itself. The OTP is extracted directly from the PAP response.
In production, Radiator usually still has local AAA work to do around the YubiKey check, such as loading the user record, checking account status, and applying authorization policy. Treat YubiCloud as the OTP validator, not as the master of your user database.
aaa {
policy "DEFAULT" {
handler "AUTHENTICATION" {
conditions all {
radius.request.code == radius.ACCESS_REQUEST;
}
@execute {
backend {
name "YUBIKEY_CLOUD";
}
}
}
}
}
Password + OTP (Two-Factor)
For cloud validation with a combined PAP value, set secret on the backend action with the yubikey() filter to send only the OTP portion to the cloud validator.
backends {
yubikey "YUBIKEY_CLOUD_OTP" {
url "https://api.yubico.com/wsapi/2.0/verify";
username "12345";
secret "base64encodedsecret=";
timeout 5s;
}
}
aaa {
policy "DEFAULT" {
handler "AUTHENTICATION" {
conditions all {
radius.request.code == radius.ACCESS_REQUEST;
}
@execute {
# Extract the YubiKey public UID once before SQL so malformed OTPs
# fail as normal authentication rejects instead of backend errors.
modify vars.presented_yubikey_public_uid = radius.request.password | yubikey(password-otp, public-uid) | recover(none);
if all {
vars.presented_yubikey_public_uid == none;
} then {
reject "Malformed YubiKey OTP";
}
backend {
name "USERS";
query "FIND_USER";
}
pap {
secret radius.request.password | yubikey(password-otp, password);
}
backend {
name "YUBIKEY_CLOUD_OTP";
secret radius.request.password | yubikey(password-otp, otp);
}
}
}
}
}
The FIND_USER query must populate user.password for pap to work. Cloud-side user lookups should follow one of two models:
- Single-key model: load one expected YubiKey public UID with the user record, then reject the request before the cloud backend call if it does not match the presented OTP.
- Multi-key model: bind on both
aaa.identityand the presented YubiKey public UID in the query itself so the lookup selects one token row directly.
For a combined <password><otp> PAP field, extract that UID before any backend call with radius.request.password | yubikey(password-otp, public-uid) | recover(none). That helper works directly on masked PAP values, and recovering to none lets the policy reject malformed input as an authentication error instead of a backend execution error.
Use that extracted value as the shared input for later checks and backend bindings:
# Extract the YubiKey public UID once before SQL so malformed OTPs
# fail as normal authentication rejects instead of backend errors.
modify vars.presented_yubikey_public_uid = radius.request.password | yubikey(password-otp, public-uid) | recover(none);
if all {
vars.presented_yubikey_public_uid == none;
} then {
reject "Malformed YubiKey OTP";
}
If your client cannot do an interactive Access-Challenge round-trip, this combined PAP field is the practical way to collect both factors in one request.
This pattern also shows the responsibility split clearly: Radiator uses its own backend to validate the account password and any local user state, while the YubiKey cloud backend validates only the OTP portion.
For example, the local USERS lookup can return more than just the password. It can also load group membership and account-expiration metadata that Radiator uses in the surrounding AAA policy.
Single-key jsonfile example
Use this model when each user has exactly one assigned YubiKey in the local account record. The query loads the expected public UID with the rest of the user state, and the pipeline rejects requests where the supplied OTP belongs to a different token.
backends {
jsonfile "USERS" {
filename "users.json";
query "FIND_USER" {
mapping {
user.password = doc | jsonpath("$.users['%{aaa.identity}'].password");
user.group = doc | jsonpath("$.users['%{aaa.identity}'].groups[*]");
vars.account_expires_at = doc | jsonpath("$.users['%{aaa.identity}'].expires_at");
vars.expected_yubikey_public_uid = doc | jsonpath("$.users['%{aaa.identity}'].yubikey_public_uid");
}
}
}
}
Reject mismatched tokens before the cloud backend call:
assert vars.expected_yubikey_public_uid vars.presented_yubikey_public_uid "YubiKey does not belong to this user";
This model is simple, but it is only suitable when each user has one active YubiKey.
Multi-key SQL example
For multiple active YubiKeys per user, use a keyed backend such as SQL and select the token row in the query itself. The SQLite example below stores one row per user/token validity window. Different tokens can be valid at the same time for the same user, because the lookup first fixes the token by public_uid and then applies the validity window. Reuse the prevalidated vars.presented_yubikey_public_uid value from the handler when binding the query.
backends {
sqlite "USERS" {
filename "users.db";
query "FIND_USER" {
statement "SELECT u.password, u.account_expires_at FROM users u JOIN user_yubikeys y ON y.username = u.username WHERE u.username = ? AND y.public_uid = ? AND (y.valid_from IS NULL OR y.valid_from <= CURRENT_TIMESTAMP) AND (y.valid_until IS NULL OR y.valid_until > CURRENT_TIMESTAMP)";
bindings {
aaa.identity;
vars.presented_yubikey_public_uid;
}
mapping {
user.password = password;
vars.account_expires_at = account_expires_at;
}
}
}
}
After the OTP is validated, Radiator can still evaluate local policy such as group-based authorization or account-expiration checks using the values loaded by FIND_USER.
Using a Self-Hosted Validation Server
Replace the YubiCloud URL with the address of a self-hosted validation server, such as YK-VAL or a compatible implementation. If you deploy YK-KSM, place it behind the validation server rather than pointing Radiator directly at the YK-KSM endpoint:
backends {
yubikey "YUBIKEY_INTERNAL" {
url "https://ykval.internal.example.com/wsapi/2.0/verify";
username "1";
secret "base64encodedkey=";
timeout 3s;
tls {
ca "INTERNAL_CA";
}
}
}
Offline Validation Configuration
Note: Offline validation is intended for testing and small deployments. See Choosing a Validation Backend for production recommendations.
Backend Schema
Store one row per token. The aes_key, usage_counter, and session_counter columns are required — the counter columns provide replay protection. Replay protection works the same way as for TOTP/HOTP: counters are loaded from the database at request time, checked against the OTP, and written back on success. There is no separate in-memory replay cache.
CREATE TABLE yubikeys (
username TEXT NOT NULL,
public_uid TEXT NOT NULL, -- 12-character modhex, e.g. "vvccccriigjn"
aes_key BLOB NOT NULL, -- 16-byte binary AES-128 key (store as BLOB, not hex string)
usage_counter INTEGER NOT NULL DEFAULT 0,
session_counter INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (username, public_uid)
);
Important:
aes_keymust be stored as a raw 16-byteBLOB, not as a hex text string. Use a SQL hex literal when inserting:X'9fafa61d1ccafd37a33d7a3703356cd5'.
Backend Configuration
backends {
sqlite "USERS" {
filename "users.db";
query "FIND_USER" {
statement "SELECT aes_key, public_uid, usage_counter, session_counter FROM yubikeys WHERE username = ? AND public_uid = ?";
bindings {
aaa.identity;
radius.request.password | yubikey(otp, public-uid);
}
mapping {
yubikey.secret = aes_key;
yubikey.public = public_uid;
yubikey.counter = usage_counter;
yubikey.session = session_counter;
}
}
statement "UPDATE_COUNTERS" {
statement "UPDATE yubikeys SET usage_counter = ?, session_counter = ? WHERE username = ? AND public_uid = ?";
bindings {
yubikey.counter;
yubikey.session;
aaa.identity;
yubikey.public;
}
}
}
}
This query shape supports multiple YubiKeys per user by selecting the row for the presented token. If the PAP field contains <password><otp> instead of only the OTP, keep the same SQL and change the second FIND_USER binding to radius.request.password | yubikey(password-otp, public-uid).
AAA Policy
aaa {
policy "DEFAULT" {
handler "AUTHENTICATION" {
conditions all {
radius.request.code == radius.ACCESS_REQUEST;
}
@execute {
backend {
name "USERS";
query "FIND_USER";
}
yubikey;
backend {
name "USERS";
query "UPDATE_COUNTERS";
}
}
}
}
}
Password + OTP (Two-Factor)
YubiKey OTP is typically used as the second factor. Users enter their static password and OTP concatenated in the PAP password field, such as mypassword<otp>. Use the secret parameter on the pap and yubikey actions together with the yubikey() filter to extract the right portion of the field:
@execute {
backend {
name "USERS";
query "FIND_USER";
}
# Validate the static password portion that sits before the OTP
pap {
secret radius.request.password | yubikey(password-otp, password);
}
# Validate the YubiKey OTP portion
yubikey {
secret radius.request.password | yubikey(password-otp, otp);
}
backend {
name "USERS";
query "UPDATE_COUNTERS";
}
}
The FIND_USER query must also populate user.password for pap to work. When the PAP field contains <password><otp>, the query should bind on both aaa.identity and radius.request.password | yubikey(password-otp, public-uid) so it selects the presented token:
SELECT password, aes_key, public_uid, usage_counter, session_counter
FROM yubikeys JOIN users USING (username)
WHERE username = ? AND public_uid = ?
Related Documentation
yubikeyaction - Offline validation action referenceyubikeycontext variables - Variables read and written during offline validation- Duo, YubiKey, and RSA-AM backends - HTTP backend reference
papaction - Combine withyubikeyfor password + OTP
- What is YubiKey?
- Cloud Validation (yubikey HTTP backend)
- Offline Validation (yubikey action)
- Choosing a Validation Backend
- Cloud Validation Configuration
- Backend Configuration
- Choosing a timeout value
- AAA Policy
- Password + OTP (Two-Factor)
- Single-key jsonfile example
- Multi-key SQL example
- Using a Self-Hosted Validation Server
- Offline Validation Configuration
- Backend Schema
- Backend Configuration
- AAA Policy
- Password + OTP (Two-Factor)
- Related Documentation
About Radiator software development security
Architecture Overview
Backend Load Balancing
Basic Installation
Built-in Environment Variables
Comparison Operators
Configuration Editor
Configuration Import and Export
Containers
Data Types
Duration Units
Environment Variables
Execution Context
Execution Pipelines
Filters
Getting a Radiator License
Health check /live and /ready
High Availability and Load Balancing
High availability identifiers
HTTP Basic Authentication
Introduction
Linux systemd support
Local AAA Backends
Log storage and formatting
Management API privilege levels
Namespaces
Password Hashing
Probabilistic Sampling
Prometheus scraping
PROXY Protocol Support
Radiator server health and boot up logic
Radiator sizing
Radiator software releases
Rate Limiting
Rate Limiting Algorithms
Reverse Dynamic Authorization
Service Level Objective
TACACS+ Authentication, Authorization, and Accounting
Template Rendering CLI
Tools radiator-client
TOTP/HOTP Authentication
What is Radiator?
YubiKey Authentication
YubiKey Context Variables
About Radiator software development security
Architecture Overview
Backend Load Balancing
Basic Installation
Built-in Environment Variables
Comparison Operators
Configuration Editor
Configuration Import and Export
Containers
Data Types
Duration Units
Environment Variables
Execution Context
Execution Pipelines
Filters
Getting a Radiator License
Health check /live and /ready
High Availability and Load Balancing
High availability identifiers
HTTP Basic Authentication
Introduction
Linux systemd support
Local AAA Backends
Log storage and formatting
Management API privilege levels
Namespaces
Password Hashing
Probabilistic Sampling
Prometheus scraping
PROXY Protocol Support
Radiator server health and boot up logic
Radiator sizing
Radiator software releases
Rate Limiting
Rate Limiting Algorithms
Reverse Dynamic Authorization
Service Level Objective
TACACS+ Authentication, Authorization, and Accounting
Template Rendering CLI
Tools radiator-client
TOTP/HOTP Authentication
What is Radiator?
YubiKey Authentication
YubiKey Context Variables