Running a RADIUS Server
pyrad2 servers are libraries you subclass - pyrad2 handles packet parsing, transport, retransmission, and replies, and you implement the business logic.
This page builds up from a minimal server to RadSec and RADIUS/1.1, in that order.
Your first server
A server is one class with two handler methods. Save this as server.py:
import asyncio
from pyrad2.server_async import ServerAsync
from pyrad2.dictionary import Dictionary
from pyrad2.host import RemoteHost
from pyrad2.constants import PacketType
class MyServer(ServerAsync):
def handle_auth_packet(self, protocol, pkt, addr):
reply = self.create_reply_packet(pkt, **{"Service-Type": "Framed-User"})
reply.code = PacketType.AccessAccept
protocol.send_response(reply, addr)
def handle_acct_packet(self, protocol, pkt, addr):
reply = self.create_reply_packet(pkt)
reply.code = PacketType.AccountingResponse
protocol.send_response(reply, addr)
async def main():
server = MyServer(
hosts={"127.0.0.1": RemoteHost("127.0.0.1", b"my-secret", "localhost")},
dictionary=Dictionary("dictionary"),
)
await server.initialize_transports(enable_auth=True, enable_acct=True)
try:
await asyncio.Event().wait()
finally:
await server.deinitialize_transports()
asyncio.run(main())
Run it:
You should see:
That's a working RADIUS server. The next sections explain what's happening and how to make it useful.
Sync or async?
The example above is async - recommended for new code. A synchronous Server class exists in pyrad2.server for legacy compatibility, but sync support may be dropped in a future release. Use server_async.py as your starting template.
Handling requests
Every handler receives:
| Argument | What it is |
|---|---|
protocol |
The transport - call protocol.send_response(reply, addr) to reply |
pkt |
A parsed Packet - already validated and decoded |
addr |
The source (host, port) tuple |
pkt behaves like a dict of attributes. Iterate it, look up values, log it - whatever your logic needs:
def handle_auth_packet(self, protocol, pkt, addr):
logger.info("Auth request id={} from {}", pkt.id, addr)
for name, values in pkt.items():
logger.info(" {}: {}", name, values)
Sending replies
Every reply starts with self.create_reply_packet(request, **attributes), then sets the response code, then goes out via the protocol:
def handle_auth_packet(self, protocol, pkt, addr):
reply = self.create_reply_packet(
pkt,
**{
"Service-Type": "Framed-User",
"Framed-IP-Address": "192.168.0.1",
"Framed-IPv6-Prefix": "fc66::1/64",
},
)
reply.code = PacketType.AccessAccept
protocol.send_response(reply, addr)
Reply codes
Reply codes live on the PacketType enum:
| Code | Use |
|---|---|
PacketType.AccessAccept |
Auth succeeded |
PacketType.AccessReject |
Auth failed |
PacketType.AccessChallenge |
Need more info (EAP, RADIUS challenge) |
PacketType.AccountingResponse |
Acknowledge an accounting record |
PacketType.CoAACK / CoANAK |
Accept/reject a CoA-Request |
PacketType.DisconnectACK / DisconnectNAK |
Accept/reject a Disconnect-Request |
See the constants reference for the complete list.
Dynamic authorization (CoA & Disconnect)
CoA and Disconnect are part of RFC 5176. They flow from a Dynamic Authorization Server to a NAS to change or terminate an active session - the opposite direction from normal auth requests.
You only need these handlers if your server is acting as a NAS or proxy and accepts CoA/Disconnect requests. Enable the listener with enable_coa=True and override the handlers:
class MyDynAuthServer(ServerAsync):
def handle_coa_packet(self, protocol, pkt, addr):
# apply session change ...
reply = self.create_reply_packet(pkt)
reply.code = PacketType.CoAACK
protocol.send_response(reply, addr)
def handle_disconnect_packet(self, protocol, pkt, addr):
# tear down session ...
reply = self.create_reply_packet(pkt)
reply.code = PacketType.DisconnectACK
protocol.send_response(reply, addr)
If you don't override them, pyrad2 responds with CoA-NAK / Disconnect-NAK and Error-Cause = Unsupported-Extension. That's the correct, clean default - you never have to write a stub just to be polite.
Status-Server health checks
RFC 5997 Status-Server requests are handled before they reach your auth/acct handlers - no extra code required.
| Arrives on | Response |
|---|---|
| Auth port (1812) | Access-Accept |
| Accounting port (1813) | Accounting-Response |
Status-Server requests must include a valid Message-Authenticator; requests without one are dropped. Your handlers are not called, so health checks never run authentication side effects.
Duplicate detection (RFC 5080)
UDP loses packets. Clients retransmit. RFC 5080 §2.2.2 requires servers to detect duplicates and resend the original reply instead of re-running the handler.
This matters most for EAP: each Access-Challenge carries a fresh State attribute, and re-processing a retransmission would mint a new State that breaks the conversation. It also matters for accounting (no double-counts) and CoA/Disconnect (no double-applying authorization changes).
Both Server and ServerAsync enable it by default. The cache key is the RFC-mandated tuple (source IP, source UDP port, code, Identifier, Request Authenticator). Retransmissions of:
Access-RequestAccounting-RequestCoA-RequestDisconnect-Request
receive the byte-identical cached reply for dedup_ttl seconds. Your handler runs exactly once. Duplicates that arrive while the original is still being processed are dropped silently.
Tuning
server = ServerAsync(
# ...
dedup_enabled=True, # default
dedup_ttl=30.0, # seconds a cached reply stays valid
dedup_max_entries=4096, # LRU cap before old entries get evicted
)
Pass dedup_enabled=False to opt out, or dedup_cache=... (a pyrad2.dedup.ResponseCache instance) to share one cache across servers or inject a custom clock for tests.
Status-Server requests, CoA/Disconnect-NAK replies, and packets where the parsed source doesn't match an allowed RemoteHost are never cached.
RadSec is exempt
RadSec runs over TCP/TLS, where the transport handles retransmission of lost segments. The dedup cache is not wired into RadSecServer.
Message-Authenticator
pyrad2 validates Message-Authenticator whenever it's present. By default:
- Packets with
EAP-Messagemust include a validMessage-Authenticator(RFC requirement). - Other packets remain compatible with older clients that don't send one.
To require it on every incoming packet, pass require_message_authenticator=True when constructing Server, ServerAsync, or RadSecServer.
RadSec - RADIUS over TLS
Status
Experimental. Implements RFC 6614.
RadSec is RADIUS over TLS/TCP instead of UDP. It replaces the MD5-based packet authentication that has aged poorly with proper TLS, and uses port 2083 for everything - auth, accounting, and dynamic authorization all share one mutually-authenticated connection.
The shared secret defaults to radsec per the RFC, but you can override it per host.
A minimal RadSec server
import os, asyncio
from pyrad2.radsec.server import RadSecServer as BaseRadSecServer
from pyrad2.dictionary import Dictionary
from pyrad2.host import RemoteHost
from pyrad2.constants import PacketType
class RadSecServer(BaseRadSecServer):
async def handle_access_request(self, packet):
reply = packet.create_reply()
reply.code = PacketType.AccessAccept
return reply
async def handle_accounting(self, packet):
reply = packet.create_reply()
reply.code = PacketType.AccountingResponse
return reply
# Optional - override only when acting as a Dynamic Authorization Server.
# Default behavior is CoA-NAK / Disconnect-NAK with Error-Cause.
# async def handle_coa(self, packet): ...
# async def handle_disconnect(self, packet): ...
async def main():
here = os.path.dirname(os.path.abspath(__file__))
server = RadSecServer(
hosts={"127.0.0.1": RemoteHost("127.0.0.1", b"radsec", "localhost")},
dictionary=Dictionary(f"{here}/dictionary"),
certfile=f"{here}/certs/server/server.cert.pem",
keyfile=f"{here}/certs/server/server.key.pem",
ca_certfile=f"{here}/certs/ca/ca.cert.pem",
)
await server.run()
asyncio.run(main())
When the server is ready you'll see:
A full example lives in examples/server_radsec.py. Test certificates ship in examples/certs/.
Async only
There is no sync RadSec server.
Health-checking a RadSec server
Status-Server health checks reuse the same TLS/TCP connection as everything else. Use examples/status_radsec.py - the UDP status.py script can't reach a RadSec server.
RADIUS/1.1 (RFC 9765)
Status
Experimental. RFC 9765 was published in April 2025 and ecosystem support is still small.
RADIUS/1.1 is a TLS-only profile of RADIUS that drops the MD5 baggage now that TLS already provides authentication, integrity, and confidentiality. Both versions share the same RadSec port (2083); the protocol version is negotiated via TLS ALPN.
| ALPN string | Profile |
|---|---|
radius/1.0 |
Historic RADIUS, MD5-based (RFC 2865) |
radius/1.1 |
RFC 9765 - no MD5, no Message-Authenticator, Token instead of Request Authenticator |
What changes in v1.1
| Area | Behavior |
|---|---|
User-Password, Tunnel-Password, MS-MPPE-*-Key |
Plain text - TLS authenticated the bytes (§5.1.1, §5.1.3, §5.1.4) |
Message-Authenticator |
Forbidden to send; silently discarded if received (§5.2) |
| Request Authenticator | Replaced by a 32-bit per-connection Token; remaining 12 bytes zero (§4.1) |
| Identifier byte | Zero on the wire - matching uses the Token (§4.1) |
| MD5 verifiers | All short-circuit - TLS already authenticated the bytes |
In your auth handler, packet["User-Password"] is the literal cleartext bytes the client sent.
Enabling v1.1
Pass radius_versions=... to the server constructor. The default is (V1_0,) for backward compatibility - no ALPN string is advertised, so historic peers see byte-identical TLS handshakes.
from pyrad2.radsec.v11 import RadiusVersion
server = RadSecServer(
# ...
radius_versions=(RadiusVersion.V1_0, RadiusVersion.V1_1),
)
Negotiation outcomes
| Server advertises | Client advertises | Result |
|---|---|---|
(V1_0,) |
(V1_0,) |
v1.0 (no ALPN sent - identical to historic RadSec) |
(V1_0, V1_1) |
(V1_0, V1_1) |
v1.1 - highest mutually supported wins |
(V1_0,) |
(V1_0, V1_1) |
v1.0 (server silent on ALPN, client falls back) |
(V1_0, V1_1) |
(V1_0,) |
v1.0 (client silent on ALPN, server falls back) |
(V1_1,) |
(V1_0,) |
Connection closed - server refuses to downgrade (§3.3) |
(V1_0,) |
(V1_1,) |
Client raises PacketError and the call returns None |
(V1_1,) |
(V1_1,) |
v1.1 |
A connection is rejected exactly when one side is configured only for v1.1 and the other side doesn't advertise the radius/1.1 ALPN.
RFC 9765 §3.4 also mandates TLS 1.3 or later whenever v1.1 is in play. RadSecServer and RadSecClient automatically promote minimum_tls_version to TLSv1_3 when v1.1 is configured.
The negotiated version is available on every parsed packet as packet.radius_version. The RadSec server logs RADSEC connection established from ... (ALPN=..., RADIUS/...) on every handshake.
Writing a v1.1-aware handler
Most handlers work unchanged. The only practical difference is the User-Password access pattern: in v1.1 it's already plaintext; in v1.0 you decrypt it as before.
async def handle_access_request(self, packet):
if packet.radius_version == RadiusVersion.V1_1:
password = packet["User-Password"][0] # plain string
else:
password = packet.pw_decrypt(packet[2][0]) # raw bytes → str
reply = packet.create_reply()
reply.code = PacketType.AccessAccept
return reply
The reply path is fully automatic: create_reply() propagates radius_version and the Token; reply_packet() skips MD5 / Message-Authenticator when v1.1 is set.