TACACS+ Authentication, Authorization, and Accounting
How to configure TACACS+ in Radiator Server for device administration, including ASCII multistage flows with one-time password challenges.
- TACACS+ Authentication, Authorization, and Accounting
- When to Use TACACS+
- Configuration Flow
- Start With a Minimal TACACS+ Server
- Choose the Transport
- Choose an Authentication Type
- OTP
- Add Multistage ASCII Authentication With Username, Password, and TOTP
- Add Privilege Escalation With a Separate Enable Password
- Phases and Variables
- Dual-Password Configuration
- Why must pap; Instead of pap;
- Operational Notes
- Gate Privileged Commands By priv-lvl
- Add Authorization and Accounting
- How the Built-in Authorization Session Cache Works
- Logging and Counters
- Trace-Level Packet Logging
- Build an RFC 9887-Compatible Profile
- See Also
TACACS+ Authentication, Authorization, and Accounting
Radiator Server implements TACACS+ as defined by RFC 8907. TACACS+ is the authentication, authorization, and accounting (AAA) protocol used by network devices (switches, routers, firewalls, load balancers) for administrator login and command authorization.
When to Use TACACS+
Use TACACS+ when you need to:
- Authenticate operators connecting to network devices (SSH, console, web management) and keep an authorization session after login
- Authorize individual CLI commands
- Record an audit trail of administrator actions (command and session accounting)
- Combine device login with multi-factor authentication
TACACS+ is separate from RADIUS. Use RADIUS for end-user network access (Wi-Fi, VPN, 802.1X) and TACACS+ for administrative access to the network devices themselves.
Configuration Flow
Read the TACACS+ configuration in this order:
- Define the TACACS+ clients and their shared secrets.
- Add a TACACS+ server in
servers { tacacs-plus ... }. - Configure the server
listenblock and choose the transport. - Choose the authentication flow used by the device.
- Add authorization and accounting handlers if the device uses them.
- Tune session, packet size, logging, and other operational settings.
Start With a Minimal TACACS+ Server
This minimal example defines one TACACS+ client and one TACACS+ server with a TCP listen block:
clients {
tacacs-plus "TACACS_DEVICES" {
client "core-switch" {
source {
ip 10.10.1.10;
}
secret "SuperSecretKey!";
}
}
}
servers {
tacacs-plus "TACACS" {
listen {
protocol tcp;
port 49;
ip 0.0.0.0;
}
clients "TACACS_DEVICES";
session-timeout 15m;
max-packet-size 4096;
policy "POLICY_TACACS_PLUS";
}
}
This example also shows the built-in TACACS+ authorization session cache. 15m is the default.
Default packet size is max-packet-size 4096;. You can raise it up to 65536 bytes or lower it per TACACS+ server.
See clients.tacacs-plus for TACACS+ client configuration details and servers.tls for the TLS server variant.
Choose the Transport
The minimal example above uses plain TCP in its listen block. Radiator also supports TLS there:
| Transport | Default port | When to use |
|---|---|---|
| TCP | 49 | Devices on a trusted management network |
| TLS (TACACS+ over TLS) | 300 | Devices that cross untrusted networks or require certificate-based identity |
If you need RFC 9887 compatibility, use TLS on port 300.
Choose an Authentication Type
TACACS+ defines four authentication types selected by the device in AuthenticationStart. Radiator implements them per RFC 8907:
authen_type | Round-trips |
|---|---|
| ASCII | Multistage (server may prompt further) |
| PAP | Single request/reply |
| CHAP | Single request/reply |
| MSCHAP | Single request/reply |
| MSCHAPv2 | Single request/reply |
Only ASCII may use the additional get-user, get-pass, and get-data continue rounds. PAP, CHAP, MSCHAP, and MSCHAPv2 must finish in a single exchange.
Do not route every TACACS+ authentication request through one generic password handler. Match the expected tacacsplus.request.action, tacacsplus.request.authentication.type, and tacacsplus.request.authentication.service, or reject unsupported combinations before running pap;. This keeps ENABLE, CHPASS, and non-password methods out of a login-specific flow.
OTP
You can use OTP with TACACS+ in two ways:
- Additional prompts. Use ASCII if Radiator must prompt separately for the OTP with
challengeand validate it in a laterAuthenticationContinue. - Embedded into the password field. If the chosen method has a cleartext password field, validate both parts from that one submission. Use
papplustotpfor<password><totp>. Usepapplusyubikeyfor<password><otp>. In both cases, extract each part from the same field.
If you need Radiator to prompt separately for the OTP, configure the device to start ASCII authentication.
Add Multistage ASCII Authentication With Username, Password, and TOTP
This is the standard interactive login flow. The device starts an ASCII authentication request. Radiator sends get-user, get-pass, and get-data prompts with challenge. It then validates the password with pap and the TOTP code with totp.
aaa {
policy "POLICY_TACACS_PLUS" {
handler "MULTISTAGE_TOTP" {
conditions all {
tacacsplus.authentication == true;
tacacsplus.request.action == tacacsplus.AUTHENTICATION_ACTION_LOGIN;
tacacsplus.request.authentication.type == tacacsplus.AUTHENTICATION_TYPE_ASCII;
tacacsplus.request.authentication.service == tacacsplus.AUTHENTICATION_SERVICE_LOGIN;
}
@execute {
# Prompt for the username if the client did not include one.
if any {
tacacsplus.request.user == none;
tacacsplus.request.user == "";
} then {
modify {
tacacsplus.reply.status = "get-user";
tacacsplus.reply.noecho = false;
}
challenge "Username";
if any {
tacacsplus.request.user == none;
tacacsplus.request.user == "";
} then {
modify tacacsplus.reply.message = "Username is required";
reject;
}
}
# Prompt for the password (no echo).
if all {
tacacsplus.request.password == none;
} then {
modify {
tacacsplus.reply.status = "get-pass";
tacacsplus.reply.noecho = true;
}
# The challenge text is sent as TACACS+ server_msg.
# It is optional, but most clients need it to know
# what to send.
challenge "Password";
}
# Prompt for the TOTP code (echo on).
modify {
tacacsplus.reply.status = "get-data";
tacacsplus.reply.noecho = false;
}
challenge "TOTP";
# Load the user record. This includes user.password and
# hmac-otp.secret, which is the stored TOTP shared secret.
backend {
name "TACACS_USERS";
query "FIND_USER";
}
# Validate the password captured in the get-pass round.
pap;
# Validate the submitted TOTP code from the current TACACS+
# message field. The shared secret comes from the backend-
# loaded hmac-otp.secret value.
totp {
secret tacacsplus.request.message;
}
# Optional text sent as TACACS+ server_msg similarly to the challenge.
modify tacacsplus.reply.message = "Welcome";
}
}
}
}
This handler is intentionally narrow. It accepts only ASCII device-login requests. Keep other TACACS+ request types, such as CHPASS, ENABLE, CHAP, MSCHAP, and MSCHAPv2, in separate handlers or reject them explicitly.
Some devices send AUTHENTICATION_SERVICE_NONE instead of AUTHENTICATION_SERVICE_LOGIN for ordinary logins. If your device does that, adjust the service guard to match the device behavior instead of removing the guard entirely.
How the rounds map onto TACACS+ packets:
The example above shows the full multistage TACACS+ ASCII flow for username, password, and TOTP in one handler.
Add Privilege Escalation With a Separate Enable Password
Cisco-style devices implement the enable command as a second TACACS+ authentication transaction, separate from the initial login. When the operator types enable (or enable 15), the device opens a new authentication session with authen_service = ENABLE and the requested privilege level (typically 15). The original login session stays at its lower privilege level until that second authentication passes.
Cisco IOS and IOS XE start both the login and the enable transactions as authen_type = ASCII. The device collects the password locally and forwards it in an AuthenticationContinue after the server replies with get-pass. Validate the enable secret in a dedicated handler that matches tacacsplus.request.authentication.service == tacacsplus.AUTHENTICATION_SERVICE_ENABLE and looks up a different stored credential than the login password. Routing the two phases to two handlers keeps the credential checks independent: a login password leak does not grant privilege 15, and the enable secret is never accepted at the login prompt.
Phases and Variables
| Phase | tacacsplus.request.action | tacacsplus.request.authentication.service | Typical tacacsplus.request.privilege | Validates against |
|---|---|---|---|---|
| Login | AUTHENTICATION_ACTION_LOGIN | AUTHENTICATION_SERVICE_LOGIN | 1 | login password |
| Enable | AUTHENTICATION_ACTION_LOGIN | AUTHENTICATION_SERVICE_ENABLE | 15 | separate enable secret |
Both phases use the LOGIN action. The device distinguishes them with the service field. Read the requested privilege from tacacsplus.request.privilege if you need to gate on the level the device asked for. Authorization that follows the enable phase carries the new priv-lvl in the authorization arguments.
Dual-Password Configuration
This example stores both passwords in one user record and exposes them through two backend queries. Each query maps a different field into user.password.
Each handler runs as ASCII to match what the device sends. If the AuthenticationStart arrives without a password, the handler replies with get-pass through challenge. The captured value lands in tacacsplus.request.password, which pap then validates against user.password. The handler uses must pap; instead of plain pap; so that a missing user record or a missing stored (enable) password rejects the request instead of falling through.
The enable handler also requires tacacsplus.request.privilege >= 2. A device asking for the enable service at priv-lvl 1 is either misconfigured or trying to bypass privilege escalation, and should not be served by the enable handler.
backends {
jsonfile "USERS" {
filename "users.jsonc";
# Login phase reads the login password.
query "FIND_USER_LOGIN" {
mapping {
user.username = doc | jsonpath("$.users['%{aaa.identity}'].username");
user.password = doc | jsonpath("$.users['%{aaa.identity}'].password");
}
}
# Enable phase reads the enable secret into user.password so that pap
# validates against the enable secret instead of the login password.
query "FIND_USER_ENABLE" {
mapping {
user.username = doc | jsonpath("$.users['%{aaa.identity}'].username");
user.password = doc | jsonpath("$.users['%{aaa.identity}'].enable_password");
}
}
}
}
aaa {
policy "POLICY_TACACS_PLUS" {
handler "LOGIN_AUTHENTICATION" {
conditions all {
tacacsplus.authentication == true;
tacacsplus.request.action == tacacsplus.AUTHENTICATION_ACTION_LOGIN;
tacacsplus.request.authentication.service == tacacsplus.AUTHENTICATION_SERVICE_LOGIN;
}
@execute {
# ASCII: prompt for the password if the device did not include one.
if all {
tacacsplus.request.password == none;
} then {
modify {
tacacsplus.reply.status = "get-pass";
tacacsplus.reply.noecho = true;
}
challenge "Password";
}
backend {
name "USERS";
query "FIND_USER_LOGIN";
}
must pap;
}
}
handler "ENABLE_AUTHENTICATION" {
conditions all {
tacacsplus.authentication == true;
tacacsplus.request.action == tacacsplus.AUTHENTICATION_ACTION_LOGIN;
tacacsplus.request.authentication.service == tacacsplus.AUTHENTICATION_SERVICE_ENABLE;
# Enable must request elevated privilege.
tacacsplus.request.privilege >= 2;
}
@execute {
# ASCII: prompt for the enable secret.
if all {
tacacsplus.request.password == none;
} then {
modify {
tacacsplus.reply.status = "get-pass";
tacacsplus.reply.noecho = true;
}
challenge "Password";
}
backend {
name "USERS";
query "FIND_USER_ENABLE";
}
must pap;
}
}
}
}
A matching users.jsonc keeps the two credentials side by side:
{
"users": {
"alice": {
"username": "alice",
"password": "alice_login_pw",
"enable_password": "alice_enable_pw",
},
},
}
Why must pap; Instead of pap;
pap returns ignore when it has nothing to compare against. That happens when the backend did not find the user, or found the user but did not populate the stored password. The default pipeline treats ignore as "this action did not decide, continue". If pap; is the last action in the handler, an ignore result lets the request finish without a reject and the device sees an accept.
must wraps a single action and converts its ignore result into reject. must pap; therefore covers three failure modes with one statement: wrong password, unknown user, and user without a stored password for the current phase. Use it for every authentication handler whose backend may omit the password field for some records.
Operational Notes
- Set the enable secret to a value distinct from the login password. If you reuse the login password as the enable secret, the second authentication adds no security beyond confirming the same credential twice.
- The device sends a fresh
AuthenticationStartfor enable, so the request always carries the username the operator typed at login. Match the user withaaa.identityand let the backend return the enable secret for that account. - A few device profiles can be configured to send the enable secret with
authen_type = PAP. In that casetacacsplus.request.passwordis already set on the first packet and theif password == nonebranch is skipped. The same handler still validates the credential withpap;. - After enable passes, the device sends authorization requests with the new
priv-lvlin the request arguments. Reflect that level back from the authorization handler if your devices require an explicitpriv-lvl=Nreply argument.
How the two phases map onto TACACS+ packets:
Gate Privileged Commands By priv-lvl
The TACACS+ session cache stores user.privilege, but it does not record whether the operator has cleared the enable secret in this session. The priv-lvl in the authorization request comes from the device. Trust the device for the current privilege level: a Cisco-style client never asks for an authorization at a privilege the operator has not actually escalated to, and the device is the entity deciding which command to run anyway. If the device itself is compromised, the TACACS+ server cannot recover the trust boundary.
For most deployments that is enough. If you want defense in depth, it is possible to refuse configuration commands at the server unless the request also carries priv-lvl = 15. The check costs one comparison and turns a misbehaving or misconfigured client into a reject instead of an accept:
handler "SHELL_AUTHORIZATION" {
conditions all {
tacacsplus.authorization == true;
}
@execute {
if all {
user.username == none;
} then {
reject "no authenticated session";
}
# Configuration commands require priv-lvl 15. The comparison
# against tacacsplus.request.args succeeds when any arg in the
# list equals the literal.
if all {
tacacsplus.request.args == "cmd=configure";
tacacsplus.request.privilege < 15;
} then {
reject "configure requires priv-lvl 15";
}
modify {
tacacsplus.reply.args += "priv-lvl=%{tacacsplus.request.privilege}";
tacacsplus.reply.args += "service=shell";
}
accept;
}
}
The same pattern applies to other commands you may want to keep at higher privilege, for example cmd=copy, cmd=write, or cmd=reload. A small inline list is easy to read in the policy. If you find yourself adding many entries, a backend lookup keyed on user.group or user.role is usually a better fit.
Add Authorization and Accounting
A TACACS+ client often also uses authorization and accounting. Add separate handlers for tacacsplus.authorization and tacacsplus.accounting:
The authorization example below expects a matching built-in TACACS+ session. It rejects the request if no session is found. The accounting example uses values from the accounting request itself.
handler "SHELL_AUTHORIZATION" {
conditions all {
tacacsplus.authorization == true;
}
@execute {
modify {
vars.args = tacacsplus.request.args;
}
if all {
user.username == none;
} then {
reject "no session";
}
if all {
user.group == "network-admins";
vars.args[0] == "service=shell";
vars.args[1] == "cmd=ls";
} then {
modify {
tacacsplus.reply.args += "priv-lvl=1";
tacacsplus.reply.args += "service=shell";
}
accept;
} else {
reject "command not allowed";
}
}
}
handler "TACACS_ACCOUNTING" {
conditions all {
tacacsplus.accounting == true;
}
@execute {
log "ACCOUNTING" {
json {
"user" tacacsplus.request.user;
"port" tacacsplus.request.port;
"address" tacacsplus.request.address;
"args" tacacsplus.request.args;
}
}
accept;
}
}
Accounting handlers should use the values in the accounting request itself, such as tacacsplus.request.user, tacacsplus.request.port, tacacsplus.request.address, and tacacsplus.request.args. Radiator does not restore cached user.* fields into accounting requests.
How the Built-in Authorization Session Cache Works
After successful TACACS+ authentication, Radiator stores a sanitized snapshot of the user record. Later authorization requests can use it without another backend lookup. The snapshot contains:
user.usernameuser.groupuser.roleuser.backenduser.privilege
Entries are stored per TACACS+ server. The key contains the server name, device IP address, TACACS+ client name, and username. Each successful authentication creates an entry. Each authorization cache hit refreshes its timeout. Control the lifetime with session-timeout in the servers { tacacs-plus "NAME" { ... } } block. Use values such as 30s, 15m, or 1h. The default is 15m. Match it to the device's session timeout, or shorten it if you need faster expiration.
The cache is not aware of the device-side session. An entry can expire while the device session is still active. It can also remain until timeout after the device session has ended. A few stale entries are inexpensive.
When no entry matches, Radiator still runs the authorization handler with user.* unset. Check user.username == none. Then either reject the request or resolve the user again from the backend. That extra lookup applies only to that one request. It does not seed the cache.
Only the fields listed above are kept. To carry additional values across authentication and authorization, use a named caches block.
Logging and Counters
Server-level TACACS+ logs use the server::tacacs-plus::<server-name> namespace. Logs emitted inside a TACACS+
AAA policy extend that namespace with ::policy::<policy-name>::handler::<handler-name>. The UI and
exported counters use the same namespace path. This lets you filter TACACS+ events by server,
policy, or handler.
Trace-Level Packet Logging
When the effective TACACS+ server log level is TRACE, Radiator writes one TACACS+ packet record for each incoming and outgoing packet. Each record includes the direction, packet type, session and sequence identifiers, and a structured packet_json object with hex dumps and an RFC-shaped field dissection.
Packet bodies in trace logs are deobfuscated.
The example below shows an incoming ASCII AuthenticationStart trace entry. It starts an interactive login for user alice on tty0 from 127.0.0.1.
{
"message": "TACACS+ packet",
"fields": {
"direction": "incoming",
"tacacs_plus_encrypted": "true",
"tacacs_plus_session_id": "16909060",
"tacacs_plus_sequence_number": "1",
"tacacs_plus_message_type": "request",
"tacacs_plus_packet_type": "authentication",
"packet_json": {
"header_hex": "c0 01 01 04 01 02 03 04 00 00 00 1a",
"header": {
"major_version": { "value": "TAC_PLUS_MAJOR_VER", "raw": 12 },
"minor_version": { "value": "TAC_PLUS_MINOR_VER_DEFAULT", "raw": 0 },
"type": { "value": "TAC_PLUS_AUTHEN", "raw": 1 },
"seq_no": 1,
"flags": [{ "value": "TAC_PLUS_SINGLE_CONNECT_FLAG", "raw": 4 }],
"session_id": 16909060,
"length": 26
},
"body_hex": "01 01 01 01 05 04 09 00 61 6c 69 63 65 74 74 79 30 31 32 37 2e 30 2e 30 2e 31",
"body": {
"action": { "value": "TAC_PLUS_AUTHEN_LOGIN", "raw": 1 },
"priv_lvl": 1,
"authen_type": { "value": "TAC_PLUS_AUTHEN_TYPE_ASCII", "raw": 1 },
"authen_service": { "value": "TAC_PLUS_AUTHEN_SVC_LOGIN", "raw": 1 },
"user": "alice",
"port": "tty0",
"rem_addr": "127.0.0.1"
}
}
}
}
Treat TACACS+ packet trace logs as sensitive data. They record logical TACACS+ body contents. That can include credentials such as PAP passwords and ASCII AuthenticationContinue message values.
Build an RFC 9887-Compatible Profile
Use this profile when you need a TACACS+ over TLS deployment that matches RFC 9887 expectations.
clients {
tacacs-plus "TACACS_TLS_CLIENTS" {
client "router-a" {
source {
ip 10.10.1.10;
}
}
}
}
servers {
tacacs-plus "TACACS_TLS" {
listen {
protocol tls;
port 300;
ip 0.0.0.0;
tls {
certificate "TACACS_SERVER_CERT";
certificate_key "TACACS_SERVER_KEY";
require_client_certificate true;
client_ca_certificate "TACACS_CLIENT_CA";
min_protocol_version tlsv13;
}
}
clients "TACACS_TLS_CLIENTS";
obfuscation disabled;
protocol-error-reply true;
policy "POLICY_TACACS_PLUS";
}
}
Set these values for an RFC 9887 TACACS+ over TLS deployment:
- Set
listen { protocol tls; port 300; }on the TACACS+ server. - Set
tls { certificate ...; certificate_key ...; }so the server presents a TLS certificate. - Set
require_client_certificate true;andclient_ca_certificate ...;to require mutual TLS and validate client certificates. - Set
min_protocol_version tlsv13;to require TLS 1.3 or later. - Set
obfuscation disabled;on the TACACS+ server to disable TACACS+ body obfuscation inside the TLS session. - When the TACACS+ server sets
obfuscation disabled;, it ignores the matched clientsecretand always uses unobfuscated TACACS+ bodies. - Set
protocol-error-reply true;when you want malformed or out-of-sequence TACACS+ requests to receive anERRORreply before the server closes the connection.
RFC 9887 requires TACACS+ data only after the TLS handshake completes. Radiator keeps 0-RTT disabled on TACACS+ TLS listeners and does not accept it.
Radiator disables TLS session resumption on TACACS+ TLS listeners by default: TLS 1.2 SessionID resumption is disabled, and TLS 1.3 session tickets are not sent. This follows RFC 9887 Section 3.6 and improves compatibility with Cisco clients. See servers.tls if you want to enable resumption explicitly.
Radiator does not currently support certificate revocation checking, such as CRLs or OCSP, for TACACS+ over TLS.
See Also
- clients.tacacs-plus - Full client reference
- servers.tls - TACACS+ over TLS server example
- actions/challenge - Driving multistage prompts
- actions/pap - PAP validation
- actions/totp - TOTP validation
- actions/hotp - HOTP validation
- TOTP/HOTP Authentication - OTP secret formats and storage
- PROXY Protocol Support - PROXY protocol on TCP and TLS
listenblocks
- TACACS+ Authentication, Authorization, and Accounting
- When to Use TACACS+
- Configuration Flow
- Start With a Minimal TACACS+ Server
- Choose the Transport
- Choose an Authentication Type
- OTP
- Add Multistage ASCII Authentication With Username, Password, and TOTP
- Add Privilege Escalation With a Separate Enable Password
- Phases and Variables
- Dual-Password Configuration
- Why must pap; Instead of pap;
- Operational Notes
- Gate Privileged Commands By priv-lvl
- Add Authorization and Accounting
- How the Built-in Authorization Session Cache Works
- Logging and Counters
- Trace-Level Packet Logging
- Build an RFC 9887-Compatible Profile
- See Also
About Radiator software development security
Architecture Overview
Backend Load Balancing
Basic Installation
Built-in Environment Variables
Byte Size Units
Certificate Revocation Lists
Comparison Operators
Configuration Editor
Configuration Import and Export
Containers
Cron and interval timers
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
Password Rehashing During Login
Probabilistic Sampling
Prometheus scraping
PROXY Protocol Support
Radiator server health and boot up logic
Radiator sizing
Radiator software releases
RadiatorDB
RadiatorDB Backup
RadiatorDB CLI
RadiatorDB Installation
RadiatorDB REST API
Rate Limiting
Rate Limiting Algorithms
Reverse Dynamic Authorization
Service Level Objective
TACACS+ Authentication, Authorization, and Accounting
Template Rendering CLI
Timestamp Format
Timestamps
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
Byte Size Units
Certificate Revocation Lists
Comparison Operators
Configuration Editor
Configuration Import and Export
Containers
Cron and interval timers
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
Password Rehashing During Login
Probabilistic Sampling
Prometheus scraping
PROXY Protocol Support
Radiator server health and boot up logic
Radiator sizing
Radiator software releases
RadiatorDB
RadiatorDB Backup
RadiatorDB CLI
RadiatorDB Installation
RadiatorDB REST API
Rate Limiting
Rate Limiting Algorithms
Reverse Dynamic Authorization
Service Level Objective
TACACS+ Authentication, Authorization, and Accounting
Template Rendering CLI
Timestamp Format
Timestamps
Tools radiator-client
TOTP/HOTP Authentication
What is Radiator?
YubiKey Authentication
YubiKey Context Variables