Radiator Server Documentation — latest

TACACS+ Authentication, Authorization, and Accounting

How to configure TACACS+ in Radiator Server for device administration, including ASCII multistage flows with one-time password challenges.

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

  1. Define the TACACS+ clients and their shared secrets.
  2. Add a TACACS+ server in servers { tacacs-plus ... }.
  3. Configure the server listen block and choose the transport.
  4. Choose the authentication flow used by the device.
  5. Add authorization and accounting handlers if the device uses them.
  6. 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:

TransportDefault portWhen to use
TCP49Devices on a trusted management network
TLS (TACACS+ over TLS)300Devices 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_typeRound-trips
ASCIIMultistage (server may prompt further)
PAPSingle request/reply
CHAPSingle request/reply
MSCHAPSingle request/reply
MSCHAPv2Single 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 challenge and validate it in a later AuthenticationContinue.
  • Embedded into the password field. If the chosen method has a cleartext password field, validate both parts from that one submission. Use pap plus totp for <password><totp>. Use pap plus yubikey for <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

Phasetacacsplus.request.actiontacacsplus.request.authentication.serviceTypical tacacsplus.request.privilegeValidates against
LoginAUTHENTICATION_ACTION_LOGINAUTHENTICATION_SERVICE_LOGIN1login password
EnableAUTHENTICATION_ACTION_LOGINAUTHENTICATION_SERVICE_ENABLE15separate 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 AuthenticationStart for enable, so the request always carries the username the operator typed at login. Match the user with aaa.identity and 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 case tacacsplus.request.password is already set on the first packet and the if password == none branch is skipped. The same handler still validates the credential with pap;.
  • After enable passes, the device sends authorization requests with the new priv-lvl in the request arguments. Reflect that level back from the authorization handler if your devices require an explicit priv-lvl=N reply 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.username
  • user.group
  • user.role
  • user.backend
  • user.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; and client_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 client secret and always uses unobfuscated TACACS+ bodies.
  • Set protocol-error-reply true; when you want malformed or out-of-sequence TACACS+ requests to receive an ERROR reply 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

Navigation
  • 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