Header-auth (Entra, Okta, Google Workspace…)
@selvajs/header-auth-provider is an auth-only adapter that trusts identity headers set by an upstream reverse proxy. Through that proxy it pairs with whichever IdP your org already uses (Microsoft Entra ID, Okta, Google Workspace), and with any data/storage provider underneath: local or supabase.
Use it when your org already authenticates through an SSO IdP and a reverse proxy (Caddy forward_auth, oauth2-proxy, Authelia) sits in front of the app.
⚠ Security: the deployment IS the boundary
This provider does no cryptographic verification. It trusts the headers it reads, so the proxy in front of it is the only thing standing between a stranger and an admin session. Nothing at runtime catches a misconfiguration.
Read the @selvajs/header-auth-provider README before deploying this. It lists the three requirements (network isolation, proxy-side auth, header scrubbing) and what each one fails like. That README owns the header names, env vars, bootstrap policy, and self-test; this page shows one concrete way to satisfy it with Entra.
Prerequisites
Selva scaffolded and running with SELVA_AUTH_PROVIDER=header (see Prerequisites and the CLI guide), plus a reverse proxy in front of it (Reverse proxy). The walkthrough uses Caddy + oauth2-proxy; any proxy that can do forward_auth and header injection works the same way.
Entra SSO via oauth2-proxy + Caddy
Browser ──HTTPS──> Caddy ──forward_auth──> oauth2-proxy ──OIDC──> Entra
│
└──reverse_proxy──> Selva (127.0.0.1:3000)
with SELVA-* identity headers injectedNeeds a real domain with an A record pointing at the host, and port 443 open. Replace [your-domain] and admin@corp.com throughout.
Part 1: Entra app registration
- Entra admin center → App registrations → New registration (single tenant).
- Copy the Application (client) ID and Directory (tenant) ID from Overview.
- Certificates & secrets → New client secret. Copy the Value immediately (shown once).
- Authentication → Add platform → Web. Add redirect URI exactly: Must be Web platform (not SPA), exact match, no trailing slash.
https://[your-domain]/oauth2/callback - API permissions: default
User.Read(Graph) is sufficient.
You now have: tenant ID, client ID, secret value.
Part 2: Install oauth2-proxy
cd /tmp
curl -fsSL -o oauth2-proxy.tar.gz
https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v7.6.0/oauth2-proxy-v7.6.0.linux-amd64.tar.gz
tar -xzf oauth2-proxy.tar.gz
sudo mv oauth2-proxy-*/oauth2-proxy /usr/local/bin/On ARM (
uname -m→aarch64), swaplinux-amd64forlinux-arm64.
Generate a cookie secret:
python3 -c 'import os,base64;print(base64.urlsafe_b64encode(os.urandom(32)).decode())'Part 3: oauth2-proxy config
sudo nano /etc/oauth2-proxy.cfgprovider = "oidc"
oidc_issuer_url = "https://login.microsoftonline.com/<TENANT_ID>/v2.0"
client_id = "<CLIENT_ID>"
client_secret = "<SECRET_VALUE>"
cookie_secret = "<COOKIE_SECRET>"
http_address = "127.0.0.1:4180"
reverse_proxy = true
redirect_url = "https://[your-domain]/oauth2/callback"
oidc_email_claim = "preferred_username"
email_domains = ["*"]
set_xauthrequest = true
skip_provider_button = true
cookie_secure = true
cookie_domains = ["[your-domain]"]Gotchas: cookie_domains is plural (a list); <TENANT_ID> goes in the middle of the issuer URL; set_xauthrequest = true makes oauth2-proxy emit X-Auth-Request-* headers; redirect_url must exactly match the Entra registration.
sudo chmod 600 /etc/oauth2-proxy.cfg
sudo oauth2-proxy --config=/etc/oauth2-proxy.cfg # test: should sit listening, not exitPart 4: oauth2-proxy as a service
sudo nano /etc/systemd/system/oauth2-proxy.service[Unit]
Description=oauth2-proxy
After=network.target
[Service]
ExecStart=/usr/local/bin/oauth2-proxy --config=/etc/oauth2-proxy.cfg
Restart=always
User=www-data
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now oauth2-proxy
sudo systemctl status oauth2-proxy # expect: active (running)If
www-datacan’t read the config:sudo chown www-data /etc/oauth2-proxy.cfgor changeUser=.
Part 5: Caddyfile
sudo nano /etc/caddy/Caddyfile[your-domain] {
encode gzip
# Strip spoofable identity headers at site scope, before any route runs.
request_header -SELVA-UserPrincipalName
request_header -SELVA-Email
request_header -SELVA-DisplayName
request_header -X-Auth-Request-User
request_header -X-Auth-Request-Email
request_header -X-Auth-Request-Preferred-Username
# oauth2-proxy's own endpoints bypass forward_auth.
handle /oauth2/* {
reverse_proxy 127.0.0.1:4180 {
header_up X-Real-IP {remote_host}
}
}
# Everything else: authenticate, map identity onto SELVA-*, proxy to Selva.
handle {
forward_auth 127.0.0.1:4180 {
uri /oauth2/auth
copy_headers X-Auth-Request-User X-Auth-Request-Email X-Auth-Request-Preferred-Username
@bad status 401
handle_response @bad {
header Location /oauth2/start?rd={http.request.uri}
respond 302
}
}
request_header SELVA-UserPrincipalName {http.request.header.X-Auth-Request-Preferred-Username}
request_header SELVA-Email {http.request.header.X-Auth-Request-Email}
reverse_proxy 127.0.0.1:3000
}
}The request_header - lines strip inbound copies and are essential against header spoofing. Two things about their placement:
- Site scope, not inside
handle. At site scope they cover every route including/oauth2/*, and run before eitherhandleblock. Inside ahandle, Caddy runsrequest_headerin its own fixed directive order rather than the order you wrote, so a strip line belowforward_authdoes not reliably strip before it. - Strip both families.
SELVA-*because that is what the provider reads, andX-Auth-Request-*becausecopy_headersforwards a client-supplied copy when oauth2-proxy doesn’t set its own.
The handle_response @bad block redirects 401s to the Entra login page; without it you get a bare unauthorized response. Use header Location ... + respond 302; redirect is not valid inside handle_response.
The two-step header dance (copy X-Auth-Request-* out of forward_auth, then set SELVA-* from them) is specific to oauth2-proxy, which emits its own header names and can’t be told to emit Selva’s. A helper that sets SELVA-* directly needs neither step: copy_headers SELVA-UserPrincipalName SELVA-Email SELVA-DisplayName and no request_header mapping. Match whichever your helper emits: the two styles need opposite .env settings, covered in Part 6.
No SELVA-DisplayName mapping is shown because oauth2-proxy has no display-name header. X-Auth-Request-User is Entra’s subject identifier, an opaque OID rather than a human name, so mapping it would materialize GUIDs as display names. Left unmapped, the UI falls back to the email/UPN. For real names, have oauth2-proxy emit the name claim as a custom header, add it to copy_headers and the strip list, then map it here.
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddyPart 6: Selva env
In ~/selva/.env:
SELVA_AUTH_PROVIDER=header
HOST=127.0.0.1
ORIGIN=https://[your-domain]
BOOTSTRAP_INSTANCE_ADMIN_EMAIL=admin@corp.comHOST=127.0.0.1 is non-negotiable. Port 3000 must not be open in the firewall.
No HEADER_AUTH_*_HEADER vars appear here on purpose: Part 5’s Caddyfile renames the headers onto SELVA-*, so the provider’s defaults already match and overriding them would break login. If you adapt that Caddyfile to forward X-Auth-Request-* unchanged instead, you must override all three: see Header names must match what reaches Selva in the README.
cd ~/selva
npm run doctor # also prints resolved header names; diff vs the Caddyfile
npm run restartPart 7: Test the round-trip
Watch both logs while you test:
sudo journalctl -u oauth2-proxy -f # in one window
cd ~/selva && npm run logs # in anotherRun the provider README’s self-test now: the direct-hit and spoofed-header checks are what prove the proxy is the boundary, and a successful login proves neither of them. Run both from a machine outside your network; from the host itself the direct hit reaches loopback and passes even with the firewall wide open.
For this setup the browser flow should go: Caddy gets 401 → redirects to Microsoft login → you authenticate → /oauth2/callback → Caddy injects SELVA-* headers → Selva loads → bootstrap admin auto-allowlisted.
Re-run the self-test after any change to the Caddyfile, oauth2-proxy config, or firewall.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
invalid keys: cookie_domain | Wrong key name | Use cookie_domains = ["..."] (plural, list) |
| Browser shows plain “unauthorized” | 401 passed straight back | Add handle_response @bad redirect block |
unrecognized directive: redirect | redirect not valid in handle_response | Use header Location ... + respond 302 |
| AADSTS50011 redirect URI mismatch | redirect_url ≠ Entra registration | Make them identical; restart oauth2-proxy |
| Bounced after login | Bootstrap email ≠ token email | Compare oauth2-proxy log email to BOOTSTRAP_INSTANCE_ADMIN_EMAIL |
oauth2-proxy.service failed | www-data can’t read config | chown www-data /etc/oauth2-proxy.cfg |
Day-2
- Restrict who can log in: replace
email_domains = ["*"]withauthenticated_emails_file = "/etc/oauth2-proxy-emails.txt"(one email per line), or useallowed_groupswith Entra groups claims. - Add users: after the first admin exists, new users must be pre-allowlisted in Admin → Users → Allowlist user. That admits the identity; grant them access to an org from Team → Members & roles.
- Rotate Entra secret: make a new secret in Entra, update
client_secretin/etc/oauth2-proxy.cfg, restart oauth2-proxy.