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 injected

Needs 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

  1. Entra admin center → App registrations → New registration (single tenant).
  2. Copy the Application (client) ID and Directory (tenant) ID from Overview.
  3. Certificates & secrets → New client secret. Copy the Value immediately (shown once).
  4. Authentication → Add platform → Web. Add redirect URI exactly:
    https://[your-domain]/oauth2/callback
    Must be Web platform (not SPA), exact match, no trailing slash.
  5. 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 -maarch64), swap linux-amd64 for linux-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.cfg
provider = "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 exit

Part 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.target
sudo systemctl daemon-reload
sudo systemctl enable --now oauth2-proxy
sudo systemctl status oauth2-proxy   # expect: active (running)

If www-data can’t read the config: sudo chown www-data /etc/oauth2-proxy.cfg or change User=.

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 either handle block. Inside a handle, Caddy runs request_header in its own fixed directive order rather than the order you wrote, so a strip line below forward_auth does not reliably strip before it.
  • Strip both families. SELVA-* because that is what the provider reads, and X-Auth-Request-* because copy_headers forwards 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 caddy

Part 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.com

HOST=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 restart

Part 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 another

Run 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

SymptomCauseFix
invalid keys: cookie_domainWrong key nameUse cookie_domains = ["..."] (plural, list)
Browser shows plain “unauthorized”401 passed straight backAdd handle_response @bad redirect block
unrecognized directive: redirectredirect not valid in handle_responseUse header Location ... + respond 302
AADSTS50011 redirect URI mismatchredirect_url ≠ Entra registrationMake them identical; restart oauth2-proxy
Bounced after loginBootstrap email ≠ token emailCompare oauth2-proxy log email to BOOTSTRAP_INSTANCE_ADMIN_EMAIL
oauth2-proxy.service failedwww-data can’t read configchown www-data /etc/oauth2-proxy.cfg

Day-2

  • Restrict who can log in: replace email_domains = ["*"] with authenticated_emails_file = "/etc/oauth2-proxy-emails.txt" (one email per line), or use allowed_groups with 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_secret in /etc/oauth2-proxy.cfg, restart oauth2-proxy.

Next