Skip to content

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-Request with EAP-Message, the client automatically adds Message-Authenticator before sending. You don't need to do anything.
  • Pass enforce_ma=True to require every reply to include a valid Message-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.

from pyrad2.client_async import ClientAsync

client = ClientAsync(...)
req = client.create_status_packet()
reply = await client.send_status_packet(req, port="auth")
from pyrad2.client import Client

client = Client(...)
req = client.create_status_packet()
reply = client.send_status_packet(req, port="auth")
port= Expected response
"auth" Access-Accept
"acct" Accounting-Response

For a RadSec server, use the dedicated TLS health-check example:

PYTHONPATH=. uv run examples/status_radsec.py

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.