Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Advanced ~35 min read

Advanced

What you will learn

  1. The subrequest architecture: a second, invisible request that shadows the client's.

  2. The full pattern: internal location, bodyless proxying, the 2xx/401/403 contract, and error_page rebranding.

  3. Claim injection: auth_request_set + response headers, and why the validator must speak headers (never JSON bodies).

  4. Scope, failure modes, and the production lineage (oauth2-proxy and friends).

  5. 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:

nginx
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 + cleared Content-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's proxy_pass_request_body row, now in its natural habitat.)

  • Authorization is inherited, not injected — the subrequest carries the client’s headers to the validator by default: Authorization, Cookie, and any X-* 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-URI gives the validator context ("protect /api/admin more strictly than /api/orders") without exposing it to the application.

  • error_page 401 = @auth_challenge turns the deny into your API's canonical challenge — with WWW-Authenticate if you add it, the shape a curl/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:

text
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 it

NGINX 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.

nginx
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):

text
{"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):

text
"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-User injected 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 write AND 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: kill b-auth, watch /api/ return 500 (fail-closed), curl -s http://127.0.0.1:9302/public/ still answers 200 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-URI gate 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_request runs 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_header is the identity pipeline: validator → variable → application header.

  • The validator location is internal (client 404), the challenge is yours to shape via error_page, and the whole pattern is what oauth2-proxy deployments have in common with the example above.

Was this page helpful?

Mark this learning resource complete to track your learning progress.