Basic Auth, JWT-ish Edge Checks, and External Auth Patterns
Learn NGINX In Action - Part 077
A production-focused guide to Basic Auth, JWT-ish edge checks, auth_request, external authorization, identity forwarding, auth caching, error mapping, and security boundaries in NGINX.
Part 077 — Basic Auth, JWT-ish Edge Checks, and External Auth Patterns
Authentication at the edge is seductive.
It looks easy:
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/htpasswd;
But production edge auth is not about adding one directive.
It is about deciding where identity is established, where authorization is enforced, and what identity claim is safe to forward.
NGINX can help at the edge, but it should not accidentally become an undocumented identity provider.
This part focuses on practical NGINX patterns:
- Basic Auth for low-complexity protection.
- External auth with
auth_request. - JWT-ish edge checks and their boundaries.
- Identity forwarding to upstream.
- Error mapping and login redirects.
- Auth caching.
- Failure modes.
- Production review checklist.
The key principle:
NGINX may enforce an edge policy.
The application still owns business authorization unless the edge policy is explicitly designed, tested, and audited as part of the authorization model.
1. Mental Model: Authentication vs Authorization vs Routing
Do not collapse these three things.
Authentication -> who is the caller?
Authorization -> is this caller allowed to do this action on this resource?
Routing -> where should this request go?
NGINX configuration often mixes them because all three are implemented as request-time decisions.
Example:
location /admin/ {
auth_basic "Admin";
auth_basic_user_file /etc/nginx/htpasswd-admin;
proxy_pass http://admin_app;
}
This does three things:
- It matches
/admin/. - It requires credentials.
- It routes to
admin_app.
That is fine for a small admin console.
It is dangerous if the team later assumes:
/admin/ is secure because NGINX routes it to admin_app.
Routing is not authorization.
A production invariant:
Every protected route must say which policy protects it, where that policy is enforced, and what happens if NGINX is bypassed.
2. Where NGINX Auth Fits
NGINX can operate at several auth layers.
Common placements:
| Pattern | Good for | Bad for |
|---|---|---|
| Basic Auth | small internal tools, emergency gates, staging environments | user lifecycle, SSO, fine-grained authorization |
auth_request | centralized auth decision, SSO delegation, sidecar auth service | ultra-low latency routes unless cached/tuned |
| JWT validation at edge | coarse API access, gateway-level claims validation | deep domain authorization, rapidly changing permissions |
| App-owned auth | business authorization, object permissions | pure edge shielding if every app must duplicate gateway checks |
| mTLS | service/client identity, machine-to-machine | user identity unless cert lifecycle is intentionally designed |
The high-quality architecture is usually hybrid:
NGINX enforces edge admissibility.
Auth service validates identity/session/token.
Application enforces domain authorization.
3. Pattern 1: Basic Auth
Basic Auth is simple and useful.
It validates username/password against a password file.
Example:
location /internal/ {
auth_basic "Internal Area";
auth_basic_user_file /etc/nginx/secrets/internal.htpasswd;
proxy_pass http://internal_app;
}
Do not confuse simple with weak.
Basic Auth can be acceptable when:
- it is behind HTTPS;
- it protects low-complexity internal routes;
- users are few;
- lifecycle is manual but controlled;
- credentials are stored outside the web root;
- logs do not leak credentials;
- it is not the main customer identity system.
It is usually a bad fit when:
- you need SSO;
- you need MFA;
- you need per-object permission;
- you need session revocation with audit trail;
- you need centralized offboarding;
- you need risk-based access decisions;
- you need identity federation.
4. Basic Auth File Placement
Bad:
/var/www/html/.htpasswd
Better:
/etc/nginx/secrets/internal.htpasswd
Or in containerized environments:
/run/secrets/nginx_internal_htpasswd
Production invariant:
The password file must not be served by any NGINX location.
Use filesystem permissions:
chown root:nginx /etc/nginx/secrets/internal.htpasswd
chmod 0640 /etc/nginx/secrets/internal.htpasswd
Then verify:
sudo -u nginx test -r /etc/nginx/secrets/internal.htpasswd
5. Basic Auth and HTTPS
Basic Auth sends credentials in a form that must be protected by TLS.
Do not expose Basic Auth over plain HTTP.
Use HTTP only for redirect or ACME challenge:
server {
listen 80;
server_name admin.example.com;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 308 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name admin.example.com;
ssl_certificate /etc/letsencrypt/live/admin.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.example.com/privkey.pem;
location / {
auth_basic "Admin";
auth_basic_user_file /etc/nginx/secrets/admin.htpasswd;
proxy_pass http://admin_app;
}
}
6. Basic Auth and satisfy
Sometimes you want either network allowlist or password.
Example:
location /admin/ {
satisfy any;
allow 10.0.0.0/8;
allow 192.168.0.0/16;
deny all;
auth_basic "Admin";
auth_basic_user_file /etc/nginx/secrets/admin.htpasswd;
proxy_pass http://admin_app;
}
Interpretation:
Request is allowed if it satisfies address rule OR Basic Auth.
This is useful for emergency access.
But it can also hide policy mistakes.
Safer alternative for most systems:
location /admin/ {
satisfy all;
allow 10.0.0.0/8;
deny all;
auth_basic "Admin";
auth_basic_user_file /etc/nginx/secrets/admin.htpasswd;
proxy_pass http://admin_app;
}
Interpretation:
Request must come from trusted network AND pass Basic Auth.
Decision table:
satisfy | Meaning | Use carefully for |
|---|---|---|
all | every access module must allow | admin endpoints, internal APIs |
any | at least one access module must allow | break-glass, trusted office network bypass |
Production invariant:
Any use of `satisfy any` must be reviewed as a bypass rule.
7. Basic Auth Footguns
7.1 Putting Auth on the Wrong Location
Bad:
location /admin {
auth_basic "Admin";
proxy_pass http://admin_app;
}
location /admin-static/ {
root /srv/public;
}
Maybe intended.
Maybe not.
Better:
location ^~ /admin/ {
auth_basic "Admin";
auth_basic_user_file /etc/nginx/secrets/admin.htpasswd;
proxy_pass http://admin_app;
}
Then add a smoke test:
curl -i https://admin.example.com/admin/ | grep '401'
curl -i https://admin.example.com/admin/assets/app.js | grep '401'
7.2 Forgetting auth_basic_user_file
A realm without user file is incomplete.
Good review rule:
Any location with `auth_basic` must have an explicit `auth_basic_user_file`, unless intentionally inherited.
7.3 Inheritance Surprise
Auth directives can be inherited.
This can be good:
server {
auth_basic "Staging";
auth_basic_user_file /etc/nginx/secrets/staging.htpasswd;
location / {
proxy_pass http://staging_app;
}
}
But disabling auth requires explicit intent:
location /healthz {
auth_basic off;
access_log off;
return 200 "ok\n";
}
Every auth_basic off should be reviewed.
7.4 Health Check Leakage
Health endpoints often need to be unauthenticated for load balancers.
That is okay only if they reveal minimal information.
Good:
location = /healthz {
auth_basic off;
access_log off;
return 200 "ok\n";
}
Bad:
location = /healthz {
auth_basic off;
proxy_pass http://app/debug/full-state;
}
8. Pattern 2: External Authorization with auth_request
auth_request makes a subrequest to an internal location.
The auth service decides whether the original request is allowed.
Minimal config:
location /api/ {
auth_request /_auth;
proxy_pass http://api_app;
}
location = /_auth {
internal;
proxy_pass http://auth_service/authorize;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization $http_authorization;
proxy_set_header Cookie $http_cookie;
}
Important:
/_auth must be internal.
Without internal, clients may call the auth endpoint through the public edge and probe its behavior.
9. auth_request Response Semantics
The auth subrequest response controls the original request:
2xx -> allow
401 -> deny with 401
403 -> deny with 403
other -> authorization error
This is intentionally simple.
Design your auth service around this contract.
Example auth service behavior:
| Auth service result | Response to subrequest | NGINX action |
|---|---|---|
| valid session | 204 | allow original request |
| no session | 401 | deny original request |
| valid identity but insufficient privilege | 403 | deny original request |
| auth service dependency down | 503 | original request fails as auth error |
| malformed token | 401 | deny original request |
Do not return 302 from the auth service and expect NGINX to behave like a browser SSO client.
If you need browser login redirects, map 401 to a login redirect at NGINX or application layer.
10. Browser Login Redirect Pattern
For browser routes:
location /app/ {
auth_request /_auth;
error_page 401 = @login_redirect;
proxy_pass http://frontend_app;
}
location @login_redirect {
return 302 https://login.example.com/start?return_to=$scheme://$host$request_uri;
}
This is acceptable only if return_to is safe.
Prefer a relative return token generated by the auth/login system.
Safer shape:
location @login_redirect {
return 302 https://login.example.com/start?return_to=$request_uri;
}
But even then, the login service must validate redirect targets.
Open redirect bugs often start here.
11. API Auth Error Pattern
For APIs, do not redirect to HTML login.
Return machine-readable errors.
location /api/ {
auth_request /_auth;
error_page 401 = @api_unauthorized;
error_page 403 = @api_forbidden;
proxy_pass http://api_app;
}
location @api_unauthorized {
default_type application/json;
add_header WWW-Authenticate 'Bearer realm="api"' always;
return 401 '{"error":"unauthorized"}\n';
}
location @api_forbidden {
default_type application/json;
return 403 '{"error":"forbidden"}\n';
}
Do not return a 200 with an error body.
Do not return HTML for API clients unless it is explicitly part of the API contract.
12. Passing Identity from Auth Service to Upstream
An auth service often returns identity headers.
Example:
X-User-Id: 12345
X-User-Email: alice@example.com
X-User-Roles: admin,billing
Use auth_request_set:
location /api/ {
auth_request /_auth;
auth_request_set $auth_user_id $upstream_http_x_user_id;
auth_request_set $auth_user_email $upstream_http_x_user_email;
auth_request_set $auth_roles $upstream_http_x_user_roles;
proxy_set_header X-User-Id $auth_user_id;
proxy_set_header X-User-Email $auth_user_email;
proxy_set_header X-User-Roles $auth_roles;
proxy_pass http://api_app;
}
But this creates a security boundary.
If clients can send X-User-Id, the app might see spoofed identity.
Strip inbound identity headers before setting trusted ones:
location /api/ {
auth_request /_auth;
auth_request_set $auth_user_id $upstream_http_x_user_id;
proxy_set_header X-User-Id $auth_user_id;
# Do not forward caller-supplied variants as-is.
proxy_set_header X-Authenticated-User $auth_user_id;
proxy_pass http://api_app;
}
Better app-side invariant:
Application trusts identity headers only from the internal NGINX network path.
Direct-to-app traffic is blocked.
13. Identity Header Naming
Avoid generic names that may already be used by clients or other proxies.
Weak:
X-User
X-Role
X-Email
Better:
X-Edge-Authenticated-User-Id
X-Edge-Authenticated-Subject
X-Edge-Authenticated-Tenant
X-Edge-Auth-Decision-Id
But do not assume long names are secure.
Security comes from:
- stripping inbound spoofed headers;
- setting headers at the trusted edge;
- blocking direct backend access;
- signing identity context when needed;
- logging auth decision IDs;
- validating expected source network.
14. auth_request and Request Body
By default, the auth subrequest should not receive the body.
This matters for uploads.
location = /_auth {
internal;
proxy_pass http://auth_service/authorize;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Method $request_method;
}
Why?
Auth usually needs metadata, not the full request body.
Sending large bodies to auth creates latency, cost, and failure coupling.
If auth needs a body digest, design that explicitly.
Do not accidentally stream every upload to auth.
15. Auth Caching
Authorization decisions can be expensive.
NGINX can cache auth subrequest responses because they are proxied responses.
Example:
proxy_cache_path /var/cache/nginx/auth levels=1:2 keys_zone=auth_cache:20m inactive=5m max_size=256m;
map $http_authorization $auth_cache_key {
default "$scheme|$host|$request_method|$uri|$http_authorization";
"" "$scheme|$host|$request_method|$uri|no-auth";
}
location = /_auth {
internal;
proxy_pass http://auth_service/authorize;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_cache auth_cache;
proxy_cache_key $auth_cache_key;
proxy_cache_valid 200 204 30s;
proxy_cache_valid 401 5s;
proxy_cache_valid 403 5s;
add_header X-Auth-Cache $upstream_cache_status always;
}
This is dangerous if the cache key is incomplete.
Do not cache auth decisions unless the key includes all dimensions that influence the decision.
Potential dimensions:
- token/session identifier;
- method;
- path;
- tenant;
- resource ID;
- audience;
- client certificate subject;
- source network;
- feature flag context;
- policy version.
Production invariant:
Auth cache key must be reviewed like a security boundary.
16. Auth Cache Staleness
Caching 200 for 5 minutes may mean revoked users remain authorized for 5 minutes.
Caching 403 for 5 minutes may mean newly granted users are blocked for 5 minutes.
Choose TTL from policy requirements, not performance wishes.
Example:
| Decision | Suggested TTL style | Reason |
|---|---|---|
unauthenticated 401 | very short | user may log in immediately |
| invalid token | short | token may be refreshed |
| valid token / coarse route | short-medium | depends on token expiry and revocation model |
| fine-grained permission | usually no cache or very short | object permissions may change |
| mTLS service allow | medium | cert identity changes slowly, but revocation matters |
If your organization requires immediate revocation, NGINX auth decision caching may be unacceptable unless backed by short token lifetime or revocation-aware design.
17. JWT-ish Edge Checks
There are three levels of JWT handling at NGINX edge.
Level 0: Do not parse JWT at NGINX. Delegate to auth service.
Level 1: Coarse token presence/shape checks at NGINX.
Level 2: Full JWT validation at NGINX using supported modules/products.
Level 0: Delegate
Most flexible:
location /api/ {
auth_request /_auth;
proxy_pass http://api_app;
}
Auth service handles:
- signature verification;
- issuer/audience checks;
- expiry;
- revocation;
- session state;
- tenant policy;
- object authorization precheck;
- OIDC integration.
This is usually the best default.
Level 1: Coarse Checks
You can block obviously unauthenticated requests before auth service.
map $http_authorization $has_bearer_token {
default 0;
~^Bearer\s+.+ 1;
}
location /api/ {
if ($has_bearer_token = 0) {
return 401;
}
auth_request /_auth;
proxy_pass http://api_app;
}
This saves some auth service calls.
But it does not validate the token.
Do not write documentation saying:
NGINX validates JWT.
It only checks presence/shape.
Level 2: Full Validation
Full JWT validation needs an actual JWT validation capability.
Depending on distribution and product boundary, this may be available through commercial/Plus modules, njs-based approaches, OpenResty/Lua, sidecars, Envoy, API gateways, or application code.
The design question is not only “can we validate signature?”
It is:
Can the edge enforce issuer, audience, expiry, not-before, key rotation, algorithm restrictions, revocation, tenant scope, and error semantics correctly?
If not, delegate to an auth service.
18. JWT Validation Footguns
18.1 Accepting alg=none
Never rely on token fields before cryptographic validation.
18.2 Not Checking Audience
A token issued for service A should not automatically access service B.
18.3 Not Checking Issuer
Multiple IdPs or environments can produce tokens that look structurally valid.
18.4 Ignoring Key Rotation
JWKS cache TTL must align with issuer rotation behavior.
18.5 Overtrusting Claims
Claims such as roles can be stale.
Fine-grained permissions should usually remain in application or auth service.
18.6 Logging Tokens
Never log full Authorization headers.
Bad:
log_format api '$remote_addr $request $http_authorization';
Better:
log_format api escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$uri",'
'"status":$status,'
'"request_id":"$request_id"'
'}';
19. External Auth Service Design
An auth service for auth_request should have a very small, stable contract.
Example request headers from NGINX:
X-Original-Method: GET
X-Original-URI: /api/orders/123?include=items
X-Original-Host: api.example.com
X-Real-IP: 203.0.113.10
X-Forwarded-For: 203.0.113.10, 10.0.1.20
Authorization: Bearer ...
Cookie: session=...
Example successful response:
HTTP/1.1 204 No Content
X-Edge-Authenticated-Subject: user:12345
X-Edge-Authenticated-Tenant: tenant:acme
X-Edge-Auth-Decision-Id: dec_01J...
Example unauthenticated response:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
Example forbidden response:
HTTP/1.1 403 Forbidden
Keep the response body small.
NGINX is not using the body for the allow/deny decision.
20. Auth Service Failure Policy
When auth service is down, should traffic fail closed or fail open?
For protected routes, default should be fail closed.
Auth unavailable -> deny or return 503, not silently allow.
Example:
location /api/ {
auth_request /_auth;
error_page 500 502 503 504 = @auth_unavailable;
proxy_pass http://api_app;
}
location @auth_unavailable {
default_type application/json;
return 503 '{"error":"auth_unavailable"}\n';
}
But be careful.
error_page 500 502 503 504 at the same location may also catch upstream app errors depending on where it is applied.
A clearer pattern is often to handle auth errors from the auth subrequest with dedicated observability and keep app errors distinct.
At minimum, log:
- auth upstream status;
- auth latency;
- auth cache status;
- auth decision ID;
- original route;
- final response status.
21. Auth Subrequest Observability
Default access logs for the original request may not show enough auth detail.
Use variables captured from auth response:
log_format protected escape=json
'{'
'"time":"$time_iso8601",'
'"request_id":"$request_id",'
'"remote_addr":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"auth_subject":"$auth_subject",'
'"auth_decision":"$auth_decision",'
'"auth_cache":"$auth_cache_status",'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"request_time":$request_time'
'}';
location /api/ {
auth_request /_auth;
auth_request_set $auth_subject $upstream_http_x_edge_authenticated_subject;
auth_request_set $auth_decision $upstream_http_x_edge_auth_decision_id;
auth_request_set $auth_cache_status $upstream_http_x_auth_cache;
access_log /var/log/nginx/api-access.log protected;
proxy_set_header X-Edge-Authenticated-Subject $auth_subject;
proxy_set_header X-Edge-Auth-Decision-Id $auth_decision;
proxy_pass http://api_app;
}
Caveat:
$upstream_* variables can include both auth subrequest and app upstream data depending on phases and logging. Test your exact log format.
When the log becomes ambiguous, separate auth access logs at the auth service and correlate by $request_id.
22. Correlation ID
Generate or propagate a request ID.
map $http_x_request_id $edge_request_id {
default $http_x_request_id;
"" $request_id;
}
location /api/ {
auth_request /_auth;
proxy_set_header X-Request-Id $edge_request_id;
proxy_pass http://api_app;
}
location = /_auth {
internal;
proxy_set_header X-Request-Id $edge_request_id;
proxy_pass http://auth_service/authorize;
}
But avoid blindly trusting external request IDs for security/audit.
For audit-critical systems, keep both:
X-External-Request-Id: caller-supplied if present
X-Edge-Request-Id: generated by edge
23. Protecting Internal Auth Endpoint
This is mandatory:
location = /_auth {
internal;
proxy_pass http://auth_service/authorize;
}
Also avoid broad regex locations that accidentally expose it.
Bad:
location ~ ^/_ {
proxy_pass http://internal_tools;
}
Add smoke tests:
curl -i https://api.example.com/_auth | grep -E '404|403|500'
Expected outcome:
External caller cannot successfully use /_auth.
24. Auth and CORS
Browser CORS and auth interact.
Preflight OPTIONS usually should not require the same auth as the actual request.
But do not accidentally allow actual requests.
Pattern:
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization,Content-Type,X-Request-Id" always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
auth_request /_auth;
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Credentials "true" always;
proxy_pass http://api_app;
}
Better for complex setups: use map to allow only approved origins, as covered earlier.
Invariant:
CORS controls browser access. Auth controls server access. They are not substitutes.
25. Auth and Static Assets
Static admin assets may need the same protection as admin APIs.
Bad:
location /admin/ {
auth_request /_auth;
proxy_pass http://admin_app;
}
location /admin-assets/ {
root /srv/public;
}
If the assets contain build-time config, routes, source maps, internal strings, or feature flags, exposing them may leak information.
Safer:
location ^~ /admin/ {
auth_request /_auth;
proxy_pass http://admin_app;
}
location ^~ /admin-assets/ {
auth_request /_auth;
root /srv/admin-static;
}
Or serve assets from the same protected app route.
26. Auth and Cache
Never cache authenticated responses by accident.
Bad:
location /api/ {
auth_request /_auth;
proxy_cache api_cache;
proxy_pass http://api_app;
}
This can leak user-specific content if the cache key is not identity-aware.
Safer default:
location /api/ {
auth_request /_auth;
proxy_cache off;
proxy_no_cache 1;
proxy_pass http://api_app;
}
If caching authenticated responses is required, go back to Part 063 and design identity-specific cache keys.
27. Auth and Rate Limiting
Rate limiting before auth and after auth answer different questions.
Before auth:
protect edge/auth service from anonymous abuse
After auth:
protect application per authenticated subject or tenant
Before auth example:
limit_req_zone $binary_remote_addr zone=preauth_ip:20m rate=10r/s;
location /api/ {
limit_req zone=preauth_ip burst=30 nodelay;
auth_request /_auth;
proxy_pass http://api_app;
}
After auth per user is harder because limit_req_zone lives at http context and key variables must be available at request processing time.
You may use identity propagated by trusted upstream only if available at the right phase. Often this is better handled by the auth service or application gateway that owns the identity lifecycle.
Do not pretend IP-based limits equal user-based limits behind NAT, mobile networks, or corporate proxies.
28. Auth and if
Avoid building authorization logic with nested if.
Weak:
if ($http_authorization = "") {
return 401;
}
if ($uri ~ admin) {
set $needs_admin 1;
}
Better:
map $http_authorization $has_bearer_token {
default 1;
"" 0;
}
location /api/ {
if ($has_bearer_token = 0) { return 401; }
auth_request /_auth;
proxy_pass http://api_app;
}
Even better:
location /api/ {
auth_request /_auth;
proxy_pass http://api_app;
}
Put policy complexity in the auth service.
NGINX is good at routing and enforcing coarse decisions.
It is not a pleasant language for complex policy trees.
29. Route-Level Auth Matrix
For production, maintain a matrix.
| Route | Auth required | Enforcer | Identity forwarded | Cache | Notes |
|---|---|---|---|---|---|
/healthz | no | NGINX exact location | no | no | minimal health only |
/metrics | yes | allowlist + Basic/mTLS | no | no | internal only |
/api/public/ | maybe | app or none | optional | maybe | no user secrets |
/api/private/ | yes | auth_request + app | subject/tenant | no | domain auth in app |
/admin/ | yes | auth_request + role | subject/roles | no | strict logging |
/assets/ | no | NGINX static | no | yes | immutable hashed assets |
This matrix is often more valuable than the config itself.
It prevents accidental unprotected paths.
30. Full Example: Browser App + API + Admin
# /etc/nginx/nginx.conf or included http file
proxy_cache_path /var/cache/nginx/auth levels=1:2 keys_zone=auth_cache:20m inactive=5m max_size=256m;
map $http_x_request_id $edge_request_id {
default $http_x_request_id;
"" $request_id;
}
upstream frontend_app {
server frontend:3000;
keepalive 32;
}
upstream api_app {
server api:8080;
keepalive 64;
}
upstream auth_service {
server auth:9000;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location = /healthz {
access_log off;
return 200 "ok\n";
}
location /assets/ {
root /srv/frontend;
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable" always;
}
location /app/ {
auth_request /_auth_browser;
error_page 401 = @login_redirect;
proxy_set_header Host $host;
proxy_set_header X-Request-Id $edge_request_id;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://frontend_app;
}
location /api/ {
auth_request /_auth_api;
error_page 401 = @api_unauthorized;
error_page 403 = @api_forbidden;
auth_request_set $auth_subject $upstream_http_x_edge_authenticated_subject;
auth_request_set $auth_tenant $upstream_http_x_edge_authenticated_tenant;
auth_request_set $auth_decision_id $upstream_http_x_edge_auth_decision_id;
proxy_set_header Host $host;
proxy_set_header X-Request-Id $edge_request_id;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Edge-Authenticated-Subject $auth_subject;
proxy_set_header X-Edge-Authenticated-Tenant $auth_tenant;
proxy_set_header X-Edge-Auth-Decision-Id $auth_decision_id;
proxy_cache off;
proxy_no_cache 1;
proxy_pass http://api_app;
}
location /admin/ {
satisfy all;
allow 10.0.0.0/8;
deny all;
auth_request /_auth_admin;
error_page 401 = @login_redirect;
error_page 403 = @admin_forbidden;
proxy_set_header Host $host;
proxy_set_header X-Request-Id $edge_request_id;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://api_app;
}
location = /_auth_browser {
internal;
proxy_pass http://auth_service/browser;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Request-Id $edge_request_id;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Host $host;
proxy_set_header Cookie $http_cookie;
}
location = /_auth_api {
internal;
proxy_pass http://auth_service/api;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Request-Id $edge_request_id;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Host $host;
proxy_set_header Authorization $http_authorization;
}
location = /_auth_admin {
internal;
proxy_pass http://auth_service/admin;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Request-Id $edge_request_id;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header Cookie $http_cookie;
}
location @login_redirect {
return 302 https://login.example.com/start?return_to=$request_uri;
}
location @api_unauthorized {
default_type application/json;
add_header WWW-Authenticate 'Bearer realm="api"' always;
return 401 '{"error":"unauthorized"}\n';
}
location @api_forbidden {
default_type application/json;
return 403 '{"error":"forbidden"}\n';
}
location @admin_forbidden {
default_type text/plain;
return 403 "forbidden\n";
}
}
This is not a universal template.
It is a pattern inventory.
In a real system, you would tune:
- auth service timeouts;
- error pages;
- auth cache policy;
- CORS;
- logging;
- direct backend access controls;
- token/session model;
- route matrix;
- tenant model.
31. Timeout Design for Auth
Auth is on the critical path.
Do not give it unbounded time.
location = /_auth_api {
internal;
proxy_connect_timeout 500ms;
proxy_send_timeout 1s;
proxy_read_timeout 1s;
proxy_pass http://auth_service/api;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
Timeout values depend on environment.
But the design rule is stable:
Auth timeout must be shorter than the client-facing route timeout.
Otherwise the application timeout budget becomes meaningless.
32. Auth Service Overload Protection
Because every protected request may call auth, auth service can become the system bottleneck.
Protect it.
limit_req_zone $binary_remote_addr zone=auth_by_ip:10m rate=20r/s;
location = /_auth_api {
internal;
limit_req zone=auth_by_ip burst=50 nodelay;
proxy_pass http://auth_service/api;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
But remember:
This limits by client IP as seen by NGINX.
It is not the same as limiting by authenticated user.
For identity-aware auth rate limits, auth service usually owns the policy.
33. Direct Backend Bypass
If backend is reachable directly, edge auth is not enough.
You need at least one of these:
- private network only;
- firewall/security group allowing only NGINX;
- service mesh/mTLS policy;
- backend validates internal edge token/header signature;
- backend performs its own auth;
- Kubernetes NetworkPolicy;
- cloud load balancer security group constraints.
Invariant:
If NGINX is an auth boundary, upstream must not accept equivalent public traffic bypassing NGINX.
34. Signed Identity Context
For high-risk systems, plain headers may not be enough.
A stronger pattern:
NGINX/auth service forwards identity context + signature.
Application verifies signature using internal shared key/public key.
Example conceptual headers:
X-Edge-Auth-Context: base64url(json)
X-Edge-Auth-Signature: base64url(signature)
The app verifies:
- signature;
- timestamp;
- audience;
- request method/path binding;
- nonce or decision ID if needed;
- issuer.
This avoids trusting arbitrary internal headers.
NGINX alone is not usually the best place to create that signature unless you intentionally add scripting/module support.
Often the auth service signs and NGINX forwards.
35. Audit Requirements
For regulated systems, auth edge logs should answer:
Who requested what?
From where?
When?
Which edge accepted it?
Which auth decision allowed/denied it?
Which policy version was used?
Which upstream handled it?
What was the final status?
Minimum fields:
time_iso8601
edge_request_id
remote_addr
real client IP after trusted realip processing
host
method
uri without sensitive query if needed
status
auth subject
auth tenant
auth decision id
auth status
auth latency
upstream app address
upstream status
request_time
Do not log:
- full bearer tokens;
- session cookies;
- raw passwords;
- PII-heavy query strings unless explicitly approved;
- full request bodies.
36. Testing Matrix
Test every protected route.
# Unauthenticated API should be 401
curl -i https://app.example.com/api/orders | head
# Invalid token should be 401
curl -i -H 'Authorization: Bearer invalid' https://app.example.com/api/orders | head
# Valid user without permission should be 403
curl -i -H 'Authorization: Bearer user_without_order_access' https://app.example.com/api/orders/123 | head
# Valid user should pass
curl -i -H 'Authorization: Bearer valid' https://app.example.com/api/orders/123 | head
# Internal auth endpoint should not be externally callable
curl -i https://app.example.com/_auth_api | head
# Spoofed identity header should not become trusted identity
curl -i \
-H 'Authorization: Bearer valid' \
-H 'X-Edge-Authenticated-Subject: attacker' \
https://app.example.com/api/me
Expected invariant for spoofing test:
Application sees the subject established by auth service, not caller-supplied header.
37. Failure Injection
Test auth service failure.
# stop auth service
# call protected route
curl -i https://app.example.com/api/orders
Expected:
No silent allow.
No infinite retry storm.
No HTML login page for API.
Clear 503 or controlled auth error.
Logs show auth upstream failure.
Test auth slowness:
auth service sleeps 2s
NGINX auth timeout is 1s
client gets controlled failure before app timeout budget is consumed
Test auth cache:
same valid token -> first request MISS, next HIT if caching enabled
revoked token -> denied within required revocation window
permission change -> observed within TTL/SLO
38. Common Incidents
Incident: All Protected Routes Return 500
Likely causes:
- auth service down;
- DNS resolution failure;
- missing
resolverfor dynamic auth upstream; - auth upstream returns unexpected status;
- NGINX cannot connect to auth service;
- auth subrequest config invalid after deploy.
Immediate checks:
nginx -T | grep -n "auth_request\|_auth\|auth_service"
tail -f /var/log/nginx/error.log
curl -i http://auth_service/authorize
Incident: Users Can Access Without Login
Likely causes:
- route matched unprotected location;
auth_basic offinherited unexpectedly;satisfy anyallowed by IP;- preflight logic accidentally applied to non-OPTIONS;
- upstream reachable directly;
- cached authenticated response leaked;
- auth endpoint returned 2xx for error path.
First action:
Find exact matched location and actual request path.
Incident: Valid Users Get 403
Likely causes:
- auth service policy change;
- missing headers forwarded to auth;
- wrong Host/URI passed to auth;
- auth cache serving stale deny;
- route matrix changed but auth service not updated;
- real IP trust boundary changed.
Incident: Login Redirect Loop
Likely causes:
- login callback route protected by same auth rule;
- session cookie not forwarded to auth;
- wrong cookie domain/path;
- HTTPS/
X-Forwarded-Protomismatch; - auth service returns 401 after successful login due to missing header.
39. Production Checklist
Before shipping edge auth:
[ ] Route matrix exists.
[ ] Protected routes have explicit auth policy.
[ ] Unprotected routes are intentionally listed.
[ ] Direct backend bypass is blocked.
[ ] Inbound spoofable identity headers are overwritten or stripped.
[ ] Auth service receives original method, URI, host, IP, request ID.
[ ] Auth service timeout is bounded.
[ ] Auth failure is fail-closed for protected routes.
[ ] API errors are API-shaped, not browser-shaped.
[ ] Browser login redirects are open-redirect safe.
[ ] Auth cache, if enabled, has reviewed key and TTL.
[ ] Sensitive credentials/tokens are not logged.
[ ] `/_auth` locations are `internal`.
[ ] Smoke tests cover unauthenticated, unauthorized, authorized, and spoofed headers.
[ ] Incident playbook exists for auth outage and accidental allow.
40. Key Takeaways
NGINX auth is powerful when used as a policy edge.
It is risky when used as an implicit identity system.
Remember:
Basic Auth is a useful gate, not SSO.
auth_request is delegation, not magic.
JWT presence is not JWT validation.
Identity headers are claims unless the trust boundary is enforced.
Auth caching is a security decision, not only a performance optimization.
A production NGINX auth design should make these facts visible in the config, tests, logs, and runbooks.
You just completed lesson 77 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.