Authentication & Access#
This is the cross-cutting reference for who can reach a service and how they sign in. It explains the model once so the per-service docs don’t have to. For the app-by-app OIDC setup and gotchas, see Family Media; for the admin panes, Status Page.
The mental model#
There is no self-hosted identity provider here. Identity lives in Microsoft Entra ID (Azure AD); Cloudflare Access is the gate at the edge that authenticates against Entra and then decides, per service, who gets in. Access is over the public internet — Tailscale stays the admin-only backhaul, not the family path.
person's browser
│
▼
┌───────────────────────┐ 1. authenticate: CF Access bounces the user to
│ Cloudflare Access │ Entra, gets back their identity + the Entra
│ team: sc2in │ security GROUPS they belong to (as claims).
│ IdP: Entra ID │
└───────────┬───────────┘ 2. authorize: the per-app Access POLICY says
│ "allow if the user is in group X". No group,
│ no entry — the app is never even reached.
▼
┌───────────────────────┐ 3. pass identity DOWNSTREAM to the app, one of
│ cloudflared tunnel │ two ways (see below): a trusted header, or a
│ → nginx loopback │ full OIDC login with CF Access as the app's
│ → the service │ own IdP.
└───────────────────────┘
The whole public surface is one attrset in Nix:
tsunaminoai.cloudflare.origins (in
hosts/x86_64-nixos/ereshkigal/default.nix).
Terranix (modules/flake/terraform/cloudflare.nix)
turns each origin into a DNS record, a Cloudflare Access application, an Allow
policy, and (for OIDC apps) a SaaS/OIDC app — plus its App Launcher tile. A
service with no origin is not on the internet: it stays Tailscale/LAN-only.
Groups → policies (the “grouping” part)#
Access is gated by Entra security group, not by individual user. Each
origin picks one via accessGroup, which resolves to an Entra group Object ID
in terraform:
accessGroup |
Entra group | Who / what it gates |
|---|---|---|
family (default) |
sg-voile-personal |
The family media tiles (Watch, Music, Audiobooks, Books, Ask) |
observability |
sg-voile-personal-admins |
Admin panes (Grafana, ntfy / alerts) |
The Access policy for an app is a single rule: allow identities whose Entra groups include this group. That is the entire authorization decision at the edge. To grant someone access to everything in a tier, you add them to that group in Entra — you never touch Cloudflare or this repo.
The group→ID mapping and the Entra IdP/team IDs are non-secret and baked into
modules/flake/terraform/default.nix(sg-voile-personal,sg-voile-personal-admins,entraIdpId,teamDomain = sc2in.cloudflareaccess.com). The Cloudflare API token is the only secret, and it comes from sops (cloudflare/terraform-token).
Claims → the app (the “how do they sign in” part)#
Once Access has let someone through, the app still needs to know who they are. Two mechanisms, chosen by what the app supports:
1. Trusted header (simplest — no app-side login)#
CF Access injects Cf-Access-Authenticated-User-Email. The origin’s nginx vhost
copies it into the header the app trusts (the identityHeader option), and the
app auto-creates/looks-up that user. No login page, no OIDC, no per-app secret.
- Used by Navidrome (
Remote-User) and paperless-ask (X-Remote-User). - Requires the app to trust a reverse-proxy header from loopback only — the
origins are
127.0.0.1-bound so the header can’t be forged from the network. - Downside: native (non-browser) clients can’t use it — e.g. a Subsonic app needs a real Navidrome password.
2. OIDC with Cloudflare Access as the IdP#
For apps that speak OIDC, terranix creates a CF Access SaaS/OIDC app and the app treats Cloudflare as its identity provider. Because the user already has an edge session, the app’s OIDC redirect is silent. The token can carry the Entra group claim, which the app can map to its own roles.
- Used by Kavita, Audiobookshelf, Jellyfin (via
jellyfin-plugin-sso), and Grafana (role-mapped). - Enable with
oidc.enable = true(+redirectURIs) on the origin. Afterapply, read the emittedclient_id/client_secret/discovery_urland set them once in the app (they live in the app’s own DB — except Grafana, whose config is in Nix via a sops EnvironmentFile). Per-app steps and the role-claim / provisioning gotchas are in Family Media §5.
Group claims for role mapping (Grafana). Grafana maps the Entra group claim
to a Grafana role (sg-voile-personal-admins → Admin, else Viewer) via
oauthRoleAttributePath. For this to work the CF Access SaaS app must be
configured to include group memberships in the OIDC token, and the JMESPath
must match the token’s actual claim shape (groups[*].id). Landing as Viewer
when you expect Admin almost always means one of those two is off.
Recipes#
Give a person access. Add them to the Entra security group for the tier:
sg-voile-personal (family) or sg-voile-personal-admins (admin). SSO
auto-provisions their app accounts on first login. Nothing to deploy. (Kavita
needs its default Login role set once — see the gotchas.)
Remove access. Remove them from the group. The edge session stops validating; the app account can be left or pruned.
Gate a new service. Add one entry to tsunaminoai.cloudflare.origins:
myapp = {
publicHostname = "myapp.inaba.network";
originPort = 91NN; # loopback nginx vhost → the app's real port
accessGroup = "family"; # or "observability" for an admin tool
displayName = "My App";
logo = "…"; tags = ["family"]; # App Launcher tile
oidc.enable = true; # only if the app speaks OIDC
redirectURIs = [ "https://myapp.inaba.network/oauth/callback" ];
};
Then deploy (origin vhost + tunnel ingress) and
nix run .#terraform-cloudflare -- apply (DNS + Access app + policy [+ OIDC
app]). Header-only apps skip oidc; wire their trusted header via
identityHeader instead.
Change who counts as an admin. Edit group membership in Entra
(sg-voile-personal-admins). For Grafana specifically, the role mapping itself
is in Nix (grafana.oauthRoleAttributePath).
Not the same thing: Entra device join#
shinobu is device/OS-joined to Entra via himmelblau (workstation login
with an Entra account). That is Entra authenticating a login session on a
machine — unrelated to the Cloudflare Access app SSO described here. The
azure-entra synthetic check in the monitors is just an up/down probe of
login.microsoftonline.com, not part of this flow either.
Verify#
# origin isolation — from a LAN host this must FAIL (loopback-only origins):
curl -k --connect-timeout 3 https://<ereshkigal-lan-ip>:91NN/ ; echo "expect: refused"
# forged identity header straight to a backend must NOT authenticate:
curl -s -H "Remote-User: mallory@example.com" http://127.0.0.1:<port>/ | head
# negative test in a browser: an Entra account NOT in the tier's group must hit
# the Cloudflare Access block page, never the app.