Radiator Server Documentation — v10.33.3

YubiKey Authentication

Yubico OTP validation with Radiator - cloud and offline paths

Table of Contents
  • 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:

  1. User sends RADIUS Access-Request with the OTP as the PAP password.
  2. Radiator sends a signed GET request to the validation server.
  3. The server verifies the OTP, checks its replay database, and returns a signed response.
  4. 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:

  1. User sends RADIUS Access-Request with the OTP as the PAP password.
  2. A backend query loads yubikey.secret (and optionally public UID and replay counters) into the authentication context.
  3. The yubikey action decrypts the OTP, verifies CRC16, checks the private UID, and compares usage and session counters against stored values.
  4. 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.identity and 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.

DeploymentRecommended backendNotes
ProductionYubiCloud or self-hosted YK-VALFull lifecycle management; YK-KSM can be used behind YK-VAL
Small / labOffline yubikey action with local databaseManual 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
    }
}
FieldRequiredDescription
urlYesBase URL of the validation server
usernameYesClient ID issued by Yubico (or your validation server)
secretYesBase64-encoded API key for HMAC request signing
timeoutYesRequest timeout (e.g. 5s, 500ms)
connectionsNoMax concurrent HTTP connections (default: 100)
tlsNoCustom 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.identity and 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_key must be stored as a raw 16-byte BLOB, 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 = ?
Navigation
  • 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