11.3 Delegated Auth: auth_request
auth_basic proves you know a password. mTLS proves who your CA says you are. auth_request lets ANY HTTP service decide — cookies, JWTs, business rules — and NGINX enforces the verdict.
Advanced
What you will learn
The subrequest architecture: a second, invisible request that shadows the client's.
The full pattern:
internallocation, bodyless proxying, the 2xx/401/403 contract, anderror_pagerebranding.Claim injection:
auth_request_set+ response headers, and why the validator must speak headers (never JSON bodies).Scope, failure modes, and the production lineage (oauth2-proxy and friends).
The end-to-end receipts: challenge, denial, and identity arriving at the application.
1. The architecture: a request within a request
auth_request /_auth; tells NGINX: before serving this location, perform a
subrequest to
/_auth — a full internal HTTP request that inherits the client's headers, runs through your config's location matching, and produces a real response. The verdict maps like this:
Subrequest result | Client's request |
|---|---|
2xx | proceeds (content handler runs) |
401 / 403 | blocked — status propagates to the client |
anything else | blocked as 500 (fail closed — validators must be reliable) |
The subrequest is invisible to the client (it's
internal), costs one loopback hop, and — this is the design win — can proxy to
any service: your session store, an OAuth introspection endpoint, a policy engine. NGINX stops being in the authentication business and becomes the enforcer.
2. The full pattern
The
:9302 server, annotated:
server {
listen 9302;
server_name api.example.test;
# 401 arrives as an ugly default page — rebrand it as the API's
# own JSON challenge (same error_page machinery as Module 3)
error_page 401 = @auth_challenge;
location @auth_challenge {
default_type application/json;
return 401 '{"error":"invalid_token","hint":"Authorization: Bearer secret-token"}';
}
# THE VALIDATOR: internal, bodyless, forwards the client's token
location = /_auth {
internal; # direct hits: 404
proxy_pass http://127.0.0.1:9080/auth; # b-auth
proxy_pass_request_body off; # don't ship the body twice
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri; # context
proxy_set_header Authorization $http_authorization;
}
location /api/ {
auth_request /_auth;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_scope $upstream_http_x_auth_request_scope;
proxy_pass http://app_api;
proxy_set_header X-Auth-User $auth_user; # identity downstream
proxy_set_header X-Auth-Scope $auth_scope;
}
location /public/ {
proxy_pass http://app_api; # auth is per-location
}
}Design decisions worth internalizing:
proxy_pass_request_body off+ clearedContent-Length— a 50 MB upload must not be transmitted twice. The subrequest is a question, not a relay. (This is the Module 4 header-hygiene table'sproxy_pass_request_bodyrow, now in its natural habitat.)Authorizationis inherited, not injected — the subrequest carries the client’s headers to the validator by default:Authorization,Cookie, and anyX-*the client chose to send all arrive uninvited. The explicit re-set in the config above documents the contract rather than creating it (and is where you would override it with a different value). Design consequence: your validator parses client-controlled input — treat it as exposed, and never trust headers it echoes back.X-Original-URIgives the validator context ("protect/api/adminmore strictly than/api/orders") without exposing it to the application.error_page 401 = @auth_challengeturns the deny into your API's canonical challenge — withWWW-Authenticateif you add it, the shape acurl/SDK client expects.
3. Claims travel as headers (the hard constraint)
Our example validator (
b-auth) mimics what real deployments (oauth2-proxy, JWT-validators) expose:
HTTP/1.1 200 OK
X-Auth-Request-User: demo-user <- claims as RESPONSE headers
X-Auth-Request-Scope: read write
{"ok":true,"sub":"demo-user",...} <- body exists but NGINX can't read itNGINX can read an upstream's
response headers (
$upstream_http_*) but
cannot parse JSON bodies — no exception, no njs here (that's Module 15). So the entire identity contract is: validator emits headers →
auth_request_set captures them into variables → your locations inject them as request headers to the application.
auth_request_set $auth_user $upstream_http_x_auth_request_user;
proxy_set_header X-Auth-User $auth_user;If your validator only speaks JSON, you put a shim in front of it or you extend it — the header contract is not optional. (Our example
auth.js was extended exactly this way, and its comment records the reasoning.)
4. The receipts
No token — the validator's 401 propagates, rebranded (real capture):
{"error":"invalid_token","hint":"Authorization: Bearer secret-token"}
status=401
Valid token — the request proceeds, and the
application's own echo shows the injected identity (real capture — this is
b-node-1 reporting what it received):
"server": "b-node-1",
"path": "/api/orders",
"x-auth-user": "demo-user",
"x-auth-scope": "read write",
Direct hit on
/_auth (real capture):
status=404 — the validator location does not exist for outside callers; only subrequests can reach it.
Wrong token:
status=401 — the validator's verdict is the only truth; NGINX adds nothing, questions nothing.
5. Production shapes
oauth2-proxy in front of internal apps: the classic — Google/GitHub/Azure AD upstream, NGINX
auth_request /oauth2/auth, cookies for browsers, tokens for APIs,X-Auth-Request-Userinjected into legacy apps that never heard of OAuth.JWT validation at the edge with njs or a microservice (Module 15 writes the njs version) — same contract, faster.
Policy engines (OPA, custom rules): "scope must include
writeAND the path must match the user's tenant" — the validator answers with 200/403 and NGINX enforces.Caching the verdict: wrap the validator location with
proxy_cache(Module 8) keyed on the token to survive validator hiccups — mind the TTL, it's your auth window.
Failure modes to design for: validator down → 500 on every protected route (fail closed — usually right, loudly tell someone); validator slow → every request pays its latency (keep it local, keep it fast); clock skew on token expiry → the validator's problem, not NGINX's.
Try it yourself
curl -si http://127.0.0.1:9302/api/orders— no token gets the challenge, a valid token gets the identity headers through, a wrong token the denial, and a direct hit on /_auth the internal-404; the receipts above show each.Break it on purpose:
killb-auth, watch/api/return 500 (fail-closed),curl -s http://127.0.0.1:9302/public/still answers200 OK(no auth, no dependency), then bring the validator back up.Add a second protected location that requires a different header (e.g.
X-Original-URIgate inside a map → validator) — the pattern composes.
Q&A
Q: Does the subrequest see the client's POST body? A: No —
proxy_pass_request_body off is the pattern
and the point. The validator decides from headers/context; bodies would double your bandwidth and leak payloads to a service that only needs to answer yes/no.
Q: Can I use
auth_request
in
stream{}
? A: No — subrequests are an HTTP concept. L4 auth is PROXY-protocol trust or nothing (Module 10).
Q: 403 vs 401 — which should my validator return? A: 401 = "I don't know who you are" (challenge-worthy), 403 = "I know exactly who you are, and no." Both propagate; only you decide which gets the
error_page rebrand.
Q: Why not just
proxy_set_header Authorization
to the app and
let IT validate? A: Then every app must validate correctly, forever, and your observability lives downstream of every bug. Enforcement at the edge is one config line; trust-on-the-inside is a culture. The mixed model (edge validates, app trusts
X-Auth-User from the proxy — never from outside) is the production norm.
Takeaways
auth_requestruns an internal subrequest before the content phase: 2xx passes, 401/403 propagate, anything else fails closed as 500.The validator contract: bodyless proxy, explicit header forwarding, claims as response headers — NGINX reads headers, never bodies, so JSON-only validators need a shim.
auth_request_set+proxy_set_headeris the identity pipeline: validator → variable → application header.The validator location is
internal(client 404), the challenge is yours to shape viaerror_page, and the whole pattern is what oauth2-proxy deployments have in common with the example above.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.