Radius Architecture¶
Topology¶
radius-ctrl splits into a data plane that must stay resident and speak UDP, and a control plane that can scale to zero.
graph TB
Browser[Browser]
subgraph "Cloudflare"
SPA[radius SPA<br/>Preact on Pages]
end
subgraph "Scaleway (per env)"
API[radius API<br/>.NET, serverless container]
FR[FreeRADIUS 3.2.10<br/>VM, UDP 1812/1813]
PG[(PostgreSQL<br/>radius-<env>)]
end
Zitadel[Zitadel<br/>OIDC]
NAS[NAS / Access Point<br/>WPA-Enterprise or captive portal]
Browser -->|HTTPS| SPA
SPA -->|REST + Bearer JWT| API
SPA -->|OIDC PKCE| Zitadel
API -->|JWKS| Zitadel
API --> PG
NAS -->|RADIUS Access-Request<br/>UDP 1812| FR
FR -->|org-scoped SQL over TLS| PG
The API and FreeRADIUS never talk directly. The control plane writes rows (NAS clients, radcheck passwords, reply attributes, certificates); FreeRADIUS reads them at auth time and writes radacct/radpostauth back. The database is the only integration point.
Why FreeRADIUS is a VM and the API is serverless: FreeRADIUS listens on UDP and must stay resident to receive Access-Requests at any moment, so it runs on a small always-on Scaleway instance. The API only serves interactive admin traffic, so it is a scale-to-zero serverless container (the first request after idle pays a cold start).
Multi-tenancy¶
One FreeRADIUS instance and one database serve every organization. Isolation is by org_id:
organizationsis the tenant table. Every FreeRADIUS table that carries user or client data (radcheck,radreply,radacct,nas, …) has a nullableorg_idcolumn.- A NAS (network access server — the AP/switch/controller sending the request) belongs to exactly one org. The NAS is what ties a request to its tenant: FreeRADIUS resolves the client by source IP against the
nastable, and the custom SQL queries scope the user lookup to that NAS'sorg_id. Two orgs can therefore reuse the same username without collision. org_idis fail-closed: the auth queries only match rows whoseorg_idequals the requesting NAS's org, so a misattributed or unknown NAS authenticates nobody.- Accounting rows get their
org_idstamped by a database trigger (radacct), not by the query, so it can't be spoofed by the NAS.
Dynamic clients: the nas table is not preloaded. FreeRADIUS resolves a client on its first packet via the dynamic_clients virtual server and caches it, so adding or editing a NAS through the control plane takes effect with no server restart (an edit self-heals within the cache lifetime).
Authentication & authorization¶
Two independent concerns:
Who is the admin — the SPA authenticates operators via Zitadel OIDC (authorization code + PKCE), requesting the project-audience scope (urn:zitadel:iam:org:project:id:<id>:aud) so the access token carries this environment's project id in aud. The API validates that token against Zitadel's JWKS (issuer = the Zitadel URL, audience = the environment's project id, Zitadel__Audience), the same issuer and audience checks nexus applies. sub/email/name claims are read verbatim.
What the admin may do — an app-local model, decoupled from token issuance:
org_memberships(subject_id, org_id, role)grants a Zitadelsuba role.org_id = NULLis a platform-wide grant.- Roles:
PLATFORM_ADMIN(any org, and org creation),ORG_OWNER(manage one org),MEMBER(read one org). IAccessControlis the single seam every request goes through (CanAccessOrgAsync,CanManageOrgAsync,IsPlatformAdminAsync); controllers never touch the grant tables directly.- An initial operator is seeded at startup from
Bootstrap:AdminSubject(a raw Zitadelsub) — no local account required.
The RADIUS end users (the people/devices authenticating over WPA/captive portal) are a separate population entirely: radius_users + their radcheck passwords and client certificates, per org. Admins are Zitadel identities; end users are not.
PKI (EAP-TLS)¶
The control plane runs a certificate authority per org for WPA-Enterprise (EAP-TLS):
certificate_authoritiesandclient_certificates, with CA private keys encrypted at rest using an AES-256 master key (Pki:MasterKey, supplied via secret/env — never in the repo).- The API issues the RADIUS server bundle and publishes CRLs; FreeRADIUS validates client certs against the org CA and refuses revoked ones. Client private keys are generated at issuance and never stored — only the public certificate is persisted.
- Device enrollment (
POST cas/{id}/enroll) issues a client cert and packages it for the target OS in one step. One issuance, many formats: a passphrase-protected.p12; the raw PEM files (cert, key, CA) individually; an Apple.mobileconfig; a ChromeOS.onc; or a Windows/Linux zip (the credential plus a Wi-Fi profile and install notes). The SSID is optional — supply it and the Wi-Fi formats also configure the network, omit it and they degrade to a certificate-only install. The passphrase is required for any format that carries the private key.
EAP-TLS mutual trust¶
EAP-TLS is mutual: FreeRADIUS validates the device's certificate, and the device validates the RADIUS server's certificate. Because one FreeRADIUS instance serves every org, the two directions use different CAs:
- Client trust (server validates device). Each org has its own CA. FreeRADIUS trusts them by reading them from
ca_path. The API container and the FreeRADIUS VM share no filesystem, so a sync job bridges the gap:GET /api/v1/provisioning/trust-bundle(token-guarded) returns every active org's CA certificate and current CRL, and a systemd timer on the VM (ca-sync.sh) writes them intoca_pathas the<subject_hash>.0/.r0files OpenSSL expects. A revoke propagates on the next sync +ca_path_reload_interval. - Server trust (device validates server). A single platform CA signs the FreeRADIUS server certificate. Enrollment installs that platform CA as the device's server-trust anchor (
Pki:ServerTrustCaPem), so every device trusts the shared server while its own identity certificate stays org-signed. The platform CA and server cert are provisioned by terraform.
Both PAP (username/password via radcheck) and EAP-TLS work end to end; eapol_test against the deployed server exercises both.
A brand-new CA propagates on two timers — the ~2-minute sync into ca_path and FreeRADIUS's ~10-minute ca_path reload — so certs from a just-created CA can take up to ~12 minutes to be accepted (certs from existing CAs are immediate). The same reload interval governs how fast a revocation takes effect. See the deployment notes to tune it.
Request path (PAP)¶
sequenceDiagram
participant NAS
participant FR as FreeRADIUS
participant PG as PostgreSQL
NAS->>FR: Access-Request (user, password) UDP 1812
FR->>PG: resolve NAS by source IP → org_id
FR->>PG: SELECT radcheck WHERE username=? AND org_id=?
PG-->>FR: Cleartext-Password
FR->>FR: compare
FR->>PG: INSERT radpostauth (+ org_id)
FR-->>NAS: Access-Accept (+ reply attributes) / Access-Reject