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.
EAP-MD5
Both sync and async clients handle the EAP-MD5 challenge round-trip transparently. Pass auth_type="eap-md5" along with User-Password; the client injects an EAP-Identity, processes the server's Access-Challenge (computing the MD5 response, copying the State), and surfaces only the final Access-Accept / Access-Reject.
req = client.create_auth_packet(
User_Name="alice",
User_Password="hunter2",
auth_type="eap-md5",
)
reply = await client.send_packet(req)
Message-Authenticator
pyrad2 validates Message-Authenticator whenever a reply includes it.
- If you build an
Access-RequestwithEAP-Message, the client automatically addsMessage-Authenticatorbefore sending. You don't need to do anything. - Pass
enforce_ma=Trueto require every reply to include a validMessage-Authenticator:
client = ClientAsync(
server="localhost",
secret=b"...",
dict=Dictionary("dictionary"),
enforce_ma=True,
)
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
RFC 9765 §3.4 mandates TLS 1.3 or later whenever v1.1 is configured. The constructor auto-promotes minimum_tls_version to TLSv1_3 in that case.