16 September 2026 · 9 min read · recloud engineering
FreeRADIUS as an identity source: signing JWTs from RADIUS events
Every ISP eventually grows a constellation of small internal services around its AAA: a session API the support desk queries, a provisioning worker that pushes plan changes, a portal that shows usage. All of them need to answer the same two questions — is this subscriber real, and is this request allowed? — and the tempting answer is to hand each service credentials to the subscriber database. Five services later, your most sensitive datastore has five sets of keys and no audit trail.
There's a neater principle, and it falls out of something FreeRADIUS already has: the server that authenticates every subscriber anyway is perfectly placed to issue cryptographic proof of what it just learned. Hook a few lines of Python into the packet lifecycle, sign a short-lived JWT with a private key that never leaves the AAA layer, and every downstream service can verify identity offline with nothing but a public key. No shared database. No shared secrets. Just signatures.
The mechanism: rlm_python at the right lifecycle points
FreeRADIUS's rlm_python module lets you attach Python functions to every stage of packet processing — authorize, post_auth, accounting, even CoA. The two that matter here:
- post_auth — runs after a subscriber successfully authenticates. This is the moment you know the identity is genuine; mint a token asserting it.
- accounting — runs on Start / Interim / Stop. Tokens minted here can assert live session facts (session id, NAS, octets so far) for services that care about "online right now".
A minimal, illustrative hook — the shape of it, not production code:
# mods-config/python/token_issuer.py
import jwt, time
PRIVATE_KEY = open("/etc/raddb/keys/issuer.pem").read()
def post_auth(p):
attrs = dict(p)
claims = {
"iss": "aaa.example.net",
"sub": attrs.get("User-Name"),
"sid": attrs.get("Acct-Session-Id"),
"nas": attrs.get("NAS-Identifier"),
"aud": "internal-apis",
"iat": int(time.time()),
"exp": int(time.time()) + 300, # five minutes is plenty
}
token = jwt.encode(claims, PRIVATE_KEY, algorithm="RS256")
publish(token) # queue, header, or reply attribute
return radiusd.RLM_MODULE_OK
What publish() does depends on your consumers: push the token onto a queue alongside the accounting record, expose it through a tiny co-located endpoint, or — for flows where the NAS itself carries state — return it in a vendor-specific reply attribute. The key design property is upstream of that choice: the private key lives only where authentication happens.
Verification: one public key, zero round-trips
Consumers verify with the public half — in Go, Python, Node, anything — checking signature, expiry and audience. There is no callback to the AAA layer and no database connection:
// any internal API, e.g. Go
tok, err := jwt.Parse(raw, keyFor, // keyFor returns the RSA public key by `kid`
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithAudience("internal-apis"),
jwt.WithExpirationRequired())
Two practices carry most of the security weight:
- Per-consumer audiences (or per-consumer keypairs). A token minted for the provisioning worker shouldn't be replayable against the billing API. Distinct
audvalues — or separate keypairs per consumer, verified from a small directory of public keys — keep the blast radius of any leak tiny. - Short expiry, key ids from day one. Five-minute tokens make revocation mostly irrelevant, and a
kidheader in every token means you can rotate keys by adding the new public key before retiring the old — no flag-day.
Operational notes from running this in production
- Don't block the worker. rlm_python runs inside FreeRADIUS worker threads; signing is fast (sub-millisecond), but publishing must not wait on a slow network call. Fire-and-forget to a local queue and let a sidecar do the delivery.
- Keys are secrets, keys have homes. The private key belongs in a Kubernetes Secret (or HSM if you have one), mounted read-only into the FreeRADIUS pod — we covered the surrounding deployment shape in FreeRADIUS on Kubernetes.
- Claims are facts, not payloads. Assert identifiers (subscriber, session, NAS) — never plan details, addresses or anything a consumer could fetch fresh. Tokens get logged; facts age.
- Clock skew is real. Verify with a minute of leeway and run NTP properly on everything that checks
exp.
When to graduate to Ory Hydra or Keycloak
The hand-rolled issuer is honest engineering for machine-to-machine trust inside one operator's stack: a dozen lines, no new moving parts, easy to reason about. It stops being the right answer when the token surface grows features:
- Subscriber-facing login — customers signing into a portal want OIDC flows, refresh tokens, remembered consent and social/passkey options. That's Keycloak territory: run it as the identity provider and back it with your subscriber store through user federation, so RADIUS and the portal agree about who exists.
- Many clients, standard discovery — once more than a handful of services verify tokens, hand-distributing PEM files gets silly. Ory Hydra gives you a spec-correct OAuth2/OIDC issuer with a JWKS endpoint; services discover keys the standard way and client-credentials flows replace bespoke minting for service-to-service calls.
- Revocation and introspection — if five-minute expiry isn't enough and you genuinely need to kill live tokens, you want an issuer with introspection endpoints, not a longer list of exceptions in your own code.
There's a comfortable middle step, too: keep FreeRADIUS as the thing that knows, but publish its verification keys through a JWKS endpoint with kid rotation. Consumers verify the standard way, and the day you introduce Hydra or Keycloak in front, they barely notice the issuer changed.
Why this pattern earns its keep
The subscriber database ends up with exactly one client — the AAA layer. Every other service holds a public key and a five-minute promise. Auditing becomes reading token logs; onboarding a new internal service becomes handing over a PEM file and an aud value. For an operator, that's the rare security improvement that also removes operational surface instead of adding it.