Skip to content

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:

uv run server.py

You should see:

[127.0.0.1:1812] Transport created
[127.0.0.1:1813] Transport created

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-Request
  • Accounting-Request
  • CoA-Request
  • Disconnect-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-Message must include a valid Message-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:

RADSEC Server with mutual TLS running on ('0.0.0.0', 2083)

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.