Reverse Dynamic Authorization
AKA Reverse CoA - Send Change of Authorization messages to NAS devices over existing outbound connections.
This article explains how to configure RADIUS Server in Radiator Policy Server to send Dynamic Authorization (DynAuth) messages such as CoA-Request and Disconnect-Request back to NAS devices over their existing outbound TLS (RadSec) or TCP connections. This pattern is also known as Reverse CoA (Change of Authorization).
Background
In a traditional RADIUS deployment, the AAA server sends DynAuth messages directly to the NAS device to a dedicated DynAuth port. This requires:
- The NAS device to have a routable IP address reachable from the AAA server
- Firewall rules allowing inbound traffic to the NAS on the DynAuth port, typically UDP/3799
- Static configuration of each NAS device's DynAuth endpoint on the AAA server
Reverse Flow
Reverse DynAuth avoids these requirements by reusing the connection that the NAS device already established to the AAA server. Since the NAS initiates the outbound connection (TLS/TCP), it traverses NAT and firewalls naturally. The AAA server then sends DynAuth messages back over that same connection, eliminating the need for separate inbound connectivity, e.g. the DynAuth port.
The following diagram illustrates the reverse flow.
In this setup, it does not matter if connections are only allowed from the NAS device to the Radiator Policy Server, because the only connection that is opened is the "RadSec connection" from the NAS to the server. The disconnect request pictured above is sent in reverse, or "upstream", over the same connection.
RadSec Server Configuration
The RadSec server configuration is mostly standard, except that it is important to disable idle timeouts on the server listen configuration to prevent long-lived connections from being closed. It is also advisable to configure the NAS device to send periodic Status-Server messages, which keeps the connection alive and provides useful information such as NAS-Identifier, NAS-Port, and other attributes that can be used in the @select pipeline for connection selection.
servers {
radius "RADSEC_SERVER" {
clients "RADIUS_CLIENTS";
listen {
protocol tls;
port 2083;
ip 0.0.0.0;
# Disable idle timeout so long-lived connections are not closed
timeout 0;
tls {
certificate "SERVER_CERT";
certificate_key "SERVER_KEY";
client_ca_certificate "CLIENT_CA";
require_client_certificate true;
}
}
}
}
Example: UDP RADIUS Triggering
Normally Radiator backends connect outbound to remote RADIUS servers, but when sending reverse DynAuth messages, the backend is configured with the reverse block instead of connect block to select from the existing inbound connections that are established by the NAS devices to the servers configured above.
In order to route incoming UDP RADIUS packets to the reverse backend a "proxy query" is required. The proxy query simply forwards the request code and attributes from the incoming UDP request to the reverse backend, and then maps the reply back to the original client.
backends {
radius "REVERSE_DYNAUTH_BACKEND" {
server "REVERSE" {
secret "radsec";
reverse {
# This pipeline is executed once for every active connection. The
# `connection` variable contains the connection context for a
# single NAS connection.
@select {
if all {
# Only consider connections on the "RADSEC_SERVER" server
connection.server_name == "RADSEC_SERVER";
# Match the connection by NAS-Identifier passed as
# RADIUS attribute in the incoming UDP packet
connection.status.nas_identifier == radius.request.attr.NAS-Identifier;
} then {
# The connection is selected when the pipeline returns `accept`
accept;
} else {
# When the pipeline rejects, the next connection is
# evaluated. If all connections are rejected, the
# backend call errors with "No matching connection"
reject;
}
}
}
}
query "PROXY_QUERY" {
bindings {
# Proxy request code and attributes from the incoming UDP packet to the reverse backend
radius.request.code = radius.request.code;
radius.request.attrs = radius.request.attrs;
}
mappings {
# Also proxy the reply code and attributes back to the original UDP client
radius.reply.code = radius.reply.code;
radius.reply.attrs = radius.reply.attrs;
}
}
}
}
You can filter the proxied attributes using the remove() and select() filters. See the RADIUS backend query documentation for details.
To receive the UDP RADIUS triggers an UDP RADIUS server must be configured with a policy which invokes the REVERSE_DYNAUTH_BACKEND with the PROXY_QUERY when the request arrives:
servers {
radius "RADIUS_UDP" {
listen {
protocol udp;
port 1812;
ip 0.0.0.0;
}
clients "LOCAL_CLIENTS";
}
}
# Policy to handle incoming UDP RADIUS packets for Reverse DynAuth triggering
aaa {
policy "PROXY_POLICY" {
conditions all {
# Invoke this policy only for requests arriving on the UDP server, not the RadSec server.
radius.server == "RADIUS_UDP";
}
handler "PROXY" {
@execute {
# If the backend call does not produce a connection match, the
# error propagates here and by default the RADIUS response is
# not sent to the triggering client causing a timeout. You can
# catch the error and return a specific response if desired
# using the `try` action.
backend {
name "REVERSE_DYNAUTH_BACKEND";
query "PROXY_QUERY";
}
}
}
}
}
With this setup it is possible to send a standard UDP RADIUS request to the RADIUS_UDP (1812 UDP) server, which proxies it to the reverse backend. The reverse backend then selects the appropriate NAS connection and sends the request in reverse over the existing RadSec/TLS connection.
Example: HTTP API Triggering
External systems may want to trigger reverse DynAuth messages via a HTTP API instead of RADIUS. This can be achieved by configuring an HTTP server that listens for API requests, and then invokes the reverse backend with a backend query that constructs the appropriate RADIUS request based on the HTTP request.
backends {
radius "REVERSE_DYNAUTH_BACKEND" {
server "REVERSE" {
secret "radsec";
reverse {
@select {
if all {
connection.server_name == "RADSEC_SERVER";
# Match the connection by nas_identifier passed as query parameter in the HTTP request
connection.status.nas_identifier == http.query.nas_identifier;
} then {
accept;
} else {
reject;
}
}
}
}
query "DISCONNECT_USER" {
bindings {
radius.request.code = radius.DISCONNECT_REQUEST;
radius.request.attr.User-Name = "%{http.query.username}";
}
}
}
}
servers {
http "HTTP_API" {
listen {
protocol tcp;
ip 0.0.0.0;
port 4000;
}
clients "HTTP_CLIENTS";
}
}
aaa {
policy "HTTP_POLICY" {
conditions all {
http.server == "HTTP_API";
}
handler "DISCONNECT" {
conditions all {
http.method == "POST";
http.path == "/disconnect";
}
@execute {
# Authentication steps, like checking HTTP Basic auth.
#backend {
# name "HTTP_AUTH_BACKEND";
# query "FIND_USER";
#}
#http-basic-auth;
# If the connection selection does not produce a connection an
# error is produced, it propagates here and a HTTP 500 response
# is returned. You can catch the error and return a different
# response if desired using the `try` action.
backend {
name "REVERSE_DYNAUTH_BACKEND";
query "DISCONNECT_USER";
}
}
}
}
}
With this configuration, send an HTTP request to disconnect a user on a specific NAS device:
curl -X POST 'http://radiator-server:4000/disconnect?nas_identifier=radiator-nas-simulator&username=mikem'
NAS Device Simulation
The following configuration shows how to set up a Radiator Policy Server instance to simulate a NAS device for testing or proxying. It connects outbound to a RadSec port and listens for incoming reverse requests on that connection.
# Outbound RadSec backend to Radiator Policy Server
backends {
radius "RADSEC_BACKEND" {
server "RADIATOR_RADSEC_SERVER" {
secret "radsec";
# Send status-server messages with a NAS-Identifier to populate connection variables for selection
status true;
nas_identifier "radiator-nas-simulator";
connect {
protocol tls;
host "radiator-radsec.example.com";
port 2083;
tls {
certificate "CLIENT_CERT";
certificate_key "CLIENT_KEY";
server_ca_certificate "SERVER_CA";
}
}
}
}
}
# Reverse listen server - receives reverse DynAuth messages over the outbound backend connection
servers {
radius "REVERSE_DYNAUTH_SERVER" {
listen {
backend "RADSEC_BACKEND";
}
clients "RADIUS_CLIENTS";
}
}
# Policy that handles incoming reverse CoA/Disconnect requests
aaa {
policy "DYNAUTH_POLICY" {
conditions all {
radius.server == "REVERSE_DYNAUTH_SERVER";
}
# This handler works just like any normal RADIUS request handler. It is
# fully agnostic to the fact that the request is received in reverse
# over an outbound connection.
handler "HANDLE_DYNAUTH" {
@execute {
# Process the DynAuth request locally, proxy it onward, or both.
if all {
radius.request.code == radius.DISCONNECT_REQUEST;
} then {
debug "Received Disconnect-Request for user %{radius.request.attr.User-Name}";
}
accept;
}
}
}
}
The listen { backend "RADSEC_BACKEND"; } block tells Radiator NAS-simulator not to open a local port. Instead, it receives RADIUS requests that arrive on the outbound TLS connections managed by RADSEC_BACKEND. See the listen block documentation for the backend mode.
It is worth mentioning that this setup is not limited to CoA or any DynAuth messages. It is possible to send and receive any RADIUS requests. This enables general RADIUS proxying use cases in restricted network environments where connections can only be initiated in one direction.
Connection Selection
The key challenge in reverse DynAuth is selecting the correct NAS connection. The @select pipeline runs for each active connection, exposing it in the connection variable. Compare it to any literals (strings, numbers, etc.) or context variables and return accept to select a connection, or reject to skip it.
See the reverse configuration for details.
Example:
@select {
if all {
# Match by server name to only consider connections from a specific server
connection.server_name == "RADSEC_SERVER";
# Match by NAS-Identifier sent in the Status-Server messages from the NAS device
connection.status.nas_identifier == "radiator-nas-simulator";
# Match using regex
connection.status.nas_identifier == /simulator/i;
# Match by peer IP address
connection.peer_ip == 10.9.0.1;
# Match by certificate attributes (for TLS connections)
connection.cert.subject.cn == "testUser";
connection.cert.subject_alt.email[0] == "testUser@demo.open.com.au";
connection.cert.subject_alt.email[1] == "otherUser@demo.open.com.au";
connection.cert.subject_alt.dns == "testUser.demo.open.com.au";
connection.cert.subject_alt.uri == "https://testUser.demo.open.com.au";
} then {
accept;
} else {
reject;
}
}
Connection Selection Using a Database
The @select block can also do any backend queries for the connection selection. For example, it is possible to query a SQL database.
@select {
backend {
name "SQL_BACKEND";
query "FIND_NAS_IDENTIFIER";
}
if all {
connection.status.nas_identifier == vars.nas_identifier;
} then {
accept;
} else {
reject;
}
}
where the SQL Backend looks like this:
backends {
sqlite "SQL_BACKEND" {
url "sqlite:sessions.sqlite";
query "FIND_NAS_IDENTIFIER" {
query "SELECT username, nas_identifier FROM users WHERE username = ?";
bindings {
radius.request.attr.User-Name;
}
mapping {
vars.nas_identifier = nas_identifier;
}
}
}
}
Matching Multiple Connections
By default, @select returns the first matching connection. However, if NAS devices make multiple connections to Radiator AAA server, you can use the round-robin selection mode to distribute requests evenly across all matching connections. This is useful for avoiding RADIUS identifier exhaustion on a single connection during high-traffic events, such as mass disconnects.
reverse {
@select round-robin {
if all {
connection.status.nas_identifier == "radiator-nas-simulator";
} then {
accept;
} else {
reject;
}
}
}
Matching the Current Connection
The execution context exposes the current RADIUS connection in radius.connection. This allows matching the current connection in the @select using the connection id:
backends {
radius "CURRENT_CONNECTION" {
server "REVERSE" {
secret "radsec";
reverse {
@select {
if all {
connection.id == radius.connection.id;
} then {
accept;
} else {
reject;
}
}
}
}
query "DISCONNECT_USER" {
bindings {
radius.request.code = radius.DISCONNECT_REQUEST;
radius.request.attr.Acct-Session-Id = radius.request.attr.Acct-Session-Id;
}
}
}
}
This allows sending an additional DynAuth request on the same connection the initial request was sent to. For example, upon receiving an Accounting-Request indicating that the user has exceeded their quota, the server can send a CoA-Request back to the NAS over the same connection to disconnect the user or change their authorization.
policy "ACCOUNTING" {
conditions all {
radius.request.code == radius.ACCOUNTING_REQUEST;
}
handler "QUOTA_CHECK" {
conditions all {
# Match interim accounting messages for active sessions
radius.request.attr.Acct-Status-Type == radius.dict.Acct-Status-Type.Alive;
}
@execute {
# Acknowledge the Accounting-Request and send the Accounting-Response.
accept;
reply;
# The backend checks the radius.request.attr.Acct-Input-Octets and
# radius.request.attr.Acct-Output-Octets against the user's quota.
backend {
name "SESSION_DATABASE";
query "QUOTA_CHECK";
}
if all {
# Variable set in the backend query mappings
vars.quota_exceeded == true;
} then {
# Quota exceeded. Send a CoA-Request back to the NAS over the same
# connection to disconnect the user
backend {
name "CURRENT_CONNECTION";
query "DISCONNECT_USER";
}
# Disconnect-Ack received
}
}
}
}
The following diagram illustrates this flow:
The session database can be any supported Radiator backend. This example uses SQLite, but for example, PostgreSQL, MariaDB, and any arbitrary REST APIs are also supported. See the backend configuration documentation.
See Also
listenconfiguration - Network and reverse listen modes for RADIUS serversreverseconfiguration - Reverse connection selection for RADIUS backends
Architecture Overview
Backend Load Balancing
Basic Installation
Comparison Operators
Configuration Editor
Configuration Import and Export
Data Types
Duration Units
Execution Context
Execution Pipelines
Filters
Health check /live and /ready
High Availability and Load Balancing
High availability identifiers
HTTP Basic Authentication
Introduction
Local AAA Backends
Log storage and formatting
Management API privilege levels
Namespaces
Password Hashing
Pipeline Directives
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
Template Rendering CLI
Tools radiator-client
TOTP/HOTP Authentication
What is Radiator?