Making RADIUS Requests
The client side of pyrad2 builds packets, sends them, handles retransmission and timeouts, and returns parsed responses. This page goes from a basic auth request to EAP, health checks, RadSec, and RADIUS/1.1.
Your first request
There are two client classes: ClientAsync (recommended) in pyrad2.client_async, and a sync Client in pyrad2.client. They share the same API surface.
from pyrad2.client_async import ClientAsync
from pyrad2.dictionary import Dictionary
from pyrad2.constants import PacketType
client = ClientAsync(
server="localhost",
secret=b"Kah3choteereethiejeimaeziecumi",
timeout=4,
dict=Dictionary("dictionary"),
)
await client.initialize_transports(enable_auth=True)
req = client.create_auth_packet(User_Name="alice")
req["NAS-IP-Address"] = "192.168.1.10"
req["Service-Type"] = "Login-User"
reply = await client.send_packet(req)
if reply.code == PacketType.AccessAccept:
print("Access accepted")
for name, values in reply.items():
print(f" {name}: {values}")
That's the whole loop: build, send, inspect.
A complete runnable version with logging and error handling lives in examples/auth_async.py.
Same dictionary on both sides
Your client and server must load compatible dictionaries. They are how each side agrees on what attribute code 1 means.
Don't hardcode secrets
The examples on this page use literal secrets for clarity. In real code, load them from your config or secrets manager.
Setting attributes
There are two ways to set attributes, and they look slightly different:
| Style | Use case |
|---|---|
create_auth_packet(User_Name="alice") |
Constructor kwargs - underscores because Python identifiers can't contain hyphens |
req["User-Name"] = "alice" |
Dict-style access - hyphens, matches the wire-name |
Both produce the same packet. Use whichever reads better in context.
req = client.create_auth_packet(User_Name="alice")
req["NAS-IP-Address"] = "192.168.1.10"
req["NAS-Port"] = 0
req["Service-Type"] = "Login-User"
req["Called-Station-Id"] = "00-04-5F-00-0F-D1"
req["Framed-IP-Address"] = "10.0.0.100"
A list of standard RADIUS attributes lives in RFC 2865 §5. Vendor-specific attributes come from your vendor dictionary.
Authentication methods
PAP (cleartext User-Password) is the default. For CHAP, EAP-MD5,
EAP-GTC, MS-CHAPv2, and EAP-MSCHAPv2, see the dedicated
Authentication Methods page. The shortest version: set
req.auth_type = "eap-md5" (or "eap-gtc", "eap-mschapv2") and the
client loop drives the challenge exchange automatically. MS-CHAPv2 /
EAP-MSCHAPv2 need an optional dependency — pip install pyrad2[mschap].
req = client.create_auth_packet(
User_Name="alice",
User_Password="hunter2",
auth_type="eap-md5",
)
reply = await client.send_packet(req)
Under the hood, the client looks up the method via pyrad2.eap.get_method(pkt.auth_type) and drives it through a transport-neutral challenge loop. Registering a new method is a one-call addition:
from pyrad2.eap import EapMethod, register_method
class FooMethod(EapMethod):
def start(self, pkt): ...
def respond(self, pkt, challenge): ...
register_method("eap-foo", FooMethod)
Callers then set req.auth_type = "eap-foo" and the existing client loop drives start before the first send and respond after every Access-Challenge until the server returns Access-Accept / Access-Reject.
Retries and backoff
Client and ClientAsync share a RetryPolicy (pyrad2.retry.RetryPolicy) covering how many retransmissions to attempt, the base wait, exponential backoff, jitter, and a hard cap. The legacy retries= / timeout= kwargs still work — they build a flat-schedule policy under the hood:
ClientAsync(server="...", retries=3, timeout=5)
# equivalent to:
ClientAsync(
server="...",
retry_policy=RetryPolicy(retries=3, timeout=5.0),
)
For backoff or jitter, pass an explicit retry_policy:
from pyrad2.retry import RetryPolicy
from pyrad2.client_async import ClientAsync
client = ClientAsync(
server="radius.example.com",
secret=b"...",
dict=dictionary,
retry_policy=RetryPolicy(
retries=4,
timeout=2.0,
backoff=2.0, # 2s, 4s, 8s, 16s
jitter=0.1, # ±10% noise per wait — avoids lockstep retries
max_wait=30.0, # cap any single wait at 30s
),
)
The async timeout handler consults wait_for(attempt) per pending request, so backoff applies to each retry independently. On the sync side, Acct-Delay-Time is bumped by the actual wait of the previous attempt (not the base timeout), so accounting requests stay correct under backoff.
Message-Authenticator
By default (enforce_ma=True) pyrad2 stamps Message-Authenticator onto every outgoing Access-Request and refuses any Access-Accept / Reject / Challenge reply that doesn't carry one. This mitigates BlastRADIUS (CVE-2024-3596) without any extra wiring on your side.
Scope of the default:
Access-RequestgetsMessage-Authenticatoradded before send; the matchingAccess-*reply is required to carry one too.- Replies to
Accounting-Request,CoA-Request, andDisconnect-Requestaren't required to includeMessage-Authenticator— their Response Authenticator MD5 already authenticates the body and shared secret. Status-Server(RFC 5997) always carriesMessage-Authenticator, regardless of the flag.- If you build an
Access-RequestwithEAP-Message,Message-Authenticatoris added automatically — same as before.
If you're talking to a legacy server that can't process the attribute, opt out explicitly:
client = ClientAsync(
server="localhost",
secret=b"...",
dict=Dictionary("dictionary"),
enforce_ma=False, # legacy peer; drops BlastRADIUS mitigation
)
Status-Server health checks
RFC 5997 Status-Server is the canonical "is this RADIUS server alive?" probe. pyrad2 adds the mandatory Message-Authenticator automatically.
port= |
Expected response |
|---|---|
"auth" |
Access-Accept |
"acct" |
Accounting-Response |
For a RadSec server, use the dedicated TLS health-check example:
The UDP examples/status.py script can't reach a RadSec server - they're on different ports and transports.
RadSec
Status
Experimental. Implements RFC 6614.
RadSec replaces UDP+MD5 with TLS/TCP on port 2083. Auth, accounting, and dynamic authorization all share one mutually-authenticated connection. The default shared secret per the RFC is radsec.
For server-side details and a discussion of what the RFC actually changes, see RadSec in the server docs.
Creating a RadSec client
from pyrad2.radsec.client import RadSecClient
from pyrad2.dictionary import Dictionary
client = RadSecClient(
server="127.0.0.1",
secret=b"radsec",
dict=Dictionary("dictionary"),
certfile="certs/client/client.cert.pem",
keyfile="certs/client/client.key.pem",
certfile_server="certs/ca/ca.cert.pem",
)
A runnable example is in examples/auth_radsec.py.
RADIUS/1.1 (RFC 9765)
Status
Experimental. See the server docs for a full description of what v1.1 changes.
RadSecClient accepts the same radius_versions=... kwarg as the server. The default (V1_0,) advertises no ALPN string at all - handshakes are byte-identical to historic RadSec. Pass (V1_0, V1_1) to offer both; the server picks the highest mutually supported version.
from pyrad2.radsec.client import RadSecClient
from pyrad2.radsec.v11 import RadiusVersion
client = RadSecClient(
server="127.0.0.1",
secret=b"radsec",
dict=Dictionary("dictionary"),
certfile="certs/client/client.cert.pem",
keyfile="certs/client/client.key.pem",
certfile_server="certs/ca/ca.cert.pem",
radius_versions=(RadiusVersion.V1_0, RadiusVersion.V1_1),
)
req = client.create_auth_packet(User_Name="alice")
req.set_obfuscated("User-Password", "hunter2")
reply = await client.send_packet(req)
print(client._negotiated_version) # RadiusVersion.V1_1 if both sides agreed
Why set_obfuscated?
A client that advertises both v1.0 and v1.1 doesn't know which one will be negotiated until the TLS handshake completes. But attribute assignment happens before that. set_obfuscated defers the encoding decision until send time:
- If v1.0 is negotiated, the password is run through
pw_crypt(). - If v1.1 wins, it's sent as plain bytes (TLS provides confidentiality).
The same helper works for Tunnel-Password, MS-MPPE-*-Key, and other encrypt=2 attributes - including vendor-specific ones, which the deferred path correctly wraps in Vendor-Specific (RADIUS attribute 26).
| Attribute type | Pass | Examples |
|---|---|---|
string |
str |
User-Password, Tunnel-Password |
octets |
bytes |
MS-MPPE-Recv-Key, MS-MPPE-Send-Key |
For v1.0-only clients, the historic req["User-Password"] = req.pw_crypt("...") pattern still works.
Strict v1.1 mode and downgrades
If your client is configured for (V1_1,) only and the server doesn't advertise the radius/1.1 ALPN, send_packet() returns None after raising PacketError internally - the client refuses to silently downgrade (RFC 9765 §3.3).
To distinguish that case from a normal timeout, check client.last_error after a None return:
last_error |
Meaning |
|---|---|
PacketError mentioning "No common RADIUS protocol" |
Strict-mode refusal |
TimeoutError |
Network timeout |
None |
Clean no-reply |
TLS version
RadSecClient defaults minimum_tls_version to TLS 1.3 (RFC 9325 treats 1.2 as legacy; RFC 9750 mandates 1.3 for RADIUS/1.1).
To bridge a legacy server that can't yet negotiate 1.3, pin the floor at 1.2 explicitly:
import ssl
from pyrad2.radsec.client import RadSecClient
client = RadSecClient(
server="legacy.example.com",
secret=b"radsec",
dict=dictionary,
minimum_tls_version=ssl.TLSVersion.TLSv1_2, # legacy peer
)
If radius_versions includes V1_1, the floor is auto-promoted to 1.3 regardless of what you pass (RFC 9750 §3.4).