Skip to content

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-&lt;env&gt;)]
    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:

  • organizations is the tenant table. Every FreeRADIUS table that carries user or client data (radcheck, radreply, radacct, nas, …) has a nullable org_id column.
  • 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 nas table, and the custom SQL queries scope the user lookup to that NAS's org_id. Two orgs can therefore reuse the same username without collision.
  • org_id is fail-closed: the auth queries only match rows whose org_id equals the requesting NAS's org, so a misattributed or unknown NAS authenticates nobody.
  • Accounting rows get their org_id stamped 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 Zitadel sub a role. org_id = NULL is a platform-wide grant.
  • Roles: PLATFORM_ADMIN (any org, and org creation), ORG_OWNER (manage one org), MEMBER (read one org).
  • IAccessControl is 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 Zitadel sub) — 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_authorities and client_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 into ca_path as the <subject_hash>.0 / .r0 files 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