6.5 Access Control: auth_basic, allow/deny, and satisfy
The final gate before content: who may even ask? Three directives, one composition rule, and a phase-order trap that bites even veterans.
Intermediate
What you will learn
auth_basic— the 30-year-old protocol that's still the right answer surprisingly often.allow/deny— the access module's ordered rule list.satisfy any|all— composing the two gates.The phase-order trap: why a
returninside a protected location bypasses auth entirely (real bug — reproduced in §4).What auth_basic is not for — and the stronger alternatives this series builds: delegated verification (Lesson 11.3, auth_request) and cryptographic client identity (Lesson 6.3, mutual TLS).
1.
auth_basic — HTTP Basic, honestly assessed
location /private/ {
auth_basic "Restricted Area"; # the realm
auth_basic_user_file htpasswd; # relative to conf dir
proxy_pass http://127.0.0.1:9001/;
}Mechanics: no credentials →
401 +
WWW-Authenticate: Basic realm="Restricted Area"; browser pops the dialog; client resends
Authorization: Basic base64(user:password); NGINX hashes (the file's format —
$apr1$… MD5-crypt in the example file, generated by
openssl passwd -apr1) and compares. A live capture walks it:
$ curl -s -o /dev/null -w '%{http_code}\n' … https://app.example.test:8643/private/
401
$ curl -sI … /private/ | grep -i www-authenticate
www-authenticate: Basic realm="Restricted Area"
$ curl -s -u guru:hunter2 … /private/ | jq -r .server
b-node-1 # admitted — the echo backend answers
$ curl -s -u guru:wrong … /private/ -o /dev/null -w '%{http_code}\n'
401The honest assessment: Basic sends
replayable credentials on every request — safe
only inside TLS (which is why it lives in this module). Given that constraint, it's a legitimately great tool: zero application code, universal client support, perfect for internal tooling, staging environments, and "one more gate" in front of admin panels. The file format supports the standard hash families (
crypt,
apr1,
{SHA}, and
$5$/
$6$ SHA-256/512-crypt — the modern choices; the example's
apr1 is the widely-compatible classic).
2.
allow /
deny — the ordered rule list
location /internal/ {
allow 127.0.0.1; # the office
deny all; # everyone else
proxy_pass http://127.0.0.1:9001/;
}Rules evaluate in order, first match wins — a tiny firewall inside your location. The output below proves both branches, including the source-address trick that makes IP tests honest (binding a second loopback address as the "foreign" client):
$ curl -s … https://app.example.test:8643/internal/ | jq -r .server
b-node-1 # 127.0.0.1 → allowed
$ curl -s --interface 127.0.0.2 … /internal/ -o /dev/null -w '%{http_code}\n'
403 # 127.0.0.2 → deniedThe full vocabulary: single IPs, CIDR ranges (
10.0.0.0/8), IPv6 (
2001:db8::/32),
all. And the classic composition idiom:
allow 10.0.0.0/8; # internal network: straight through
allow 192.0.2.0/24; # the office
deny all; # everyone else: no /internal for youTwo facts people learn the hard way:
the access module sees the TCP peer — behind a proxy/LB that's the middlebox, so put the Lesson 4.2
real_ip configuration first or your allowlist authorizes your LB; and
deny
produces 403, not 404 — don't "hide" paths with it and expect them to look nonexistent (Module 7 has the truly-silent options).
3.
satisfy — composing the gates
location /satisfy-any/ { # EITHER the IP OR the password:
allow 127.0.0.1;
deny all;
auth_basic "Restricted Area";
auth_basic_user_file htpasswd;
satisfy any;
proxy_pass http://127.0.0.1:9001/;
}
location /satisfy-all/ { # BOTH (this is the default):
allow 127.0.0.1;
deny all;
auth_basic "Restricted Area";
auth_basic_user_file htpasswd;
satisfy all;
proxy_pass http://127.0.0.1:9001/;
}The truth table, all four cells live:
Test |
|
|
|---|---|---|
trusted IP, no credentials | 200 | 401 |
untrusted IP, valid credentials | 200 | 403 |
untrusted IP, no credentials | 401 | 401/403 |
trusted IP + credentials | 200 | 200 |
Read the power in row 2:
satisfy any is how you let
either the office network
or password-holders into staging — each population uses its natural credential.
satisfy all is defense in depth: network position isn't enough, passwords aren't enough, you need both.
4. The phase-order trap (a real-world bug pattern)
A common first attempt at protected locations looks like this:
location /private/ {
auth_basic "Restricted Area";
auth_basic_user_file htpasswd;
return 200 "welcome to the private area\n"; # ← THE BUG
}
Every request got a 200 — no credentials required. The cause is NGINX's request pipeline (Module 11's full story):
return is a
rewrite-phase directive, and the rewrite phase runs
before the
access phase where
auth_basic and
allow/deny live. The content never reached the gate because
return ended the request two phases early. The fix — serve real content through the phase machine:
location /private/ {
auth_basic …;
proxy_pass http://127.0.0.1:9001/; # static files / proxying /
} # try_files — all post-access5. What auth_basic is not — and what comes later
Basic auth is a perimeter tool, not an identity system: one shared password file, no sessions, no logout, no MFA, no scoping. The series' escalation path:
auth_request(Module 11): delegate the decision to any HTTP service (your login system, OAuth introspection, policy engines) — NGINX just asks "403 or 200?" per request. The universal adapter.JWT/SSO validation (Module 15): njs or Plus verify tokens at the edge — signed identity without a subrequest hop.
mTLS (Lesson 6.3): cryptographic identity, the strongest gate of all, for populations that can carry certificates.
The pattern that survives every scale: perimeter via IP/cert at the connection, identity via auth_request at the request, authorization in the app — each layer answering the question it's best at.
Try it yourself
Reproduce the truth table (§3) — all four cells, with
--interface 127.0.0.2playing "the internet."Reproduce the trap (§4): add
return 200 "bypassed\n";to/satisfy-all/, reload, and watch auth evaporate. Remove it, and feel the phases snap back into order. (Two minutes that will save you a future outage.)Credential operations. Add a second user to the htpasswd (
openssl passwd -apr1 'second-secret'), swap the file — note that no reload is needed — and log in as both users.The real_ip interaction. Re-read Lesson 4.2's real_ip block and reason: what breaks in §2's allowlist if NGINX sat behind an LB? (Answer from the armchair; Module 12's log fields let you prove peer vs real-IP on real traffic.)
Q&A
Q1. Why is Basic auth acceptable in 2026 at all?
Answer
Inside TLS (this module's whole point), the replay-theft window is closed by transport security; the remaining properties — zero app code, universal clients, hot-swappable files — make it ideal for perimeters and internal tooling. It's the identity/authorization jobs it was never meant for.
Q2.
allow 10.0.0.0/8; deny all; — but behind an AWS ALB, everyone gets 403 including the office. Why, and the two-line fix?
Answer
The access module evaluates the TCP peer = the ALB's address; your allowlist authorizes the LB, and
deny all eats the rest. Fix with the real_ip module:
set_real_ip_from <ALB ranges>; real_ip_header X-Forwarded-For; — then
$remote_addr (what allow/deny see) is the client again.
Q3. Staging must admit the office network or anyone with the shared password. One directive decides which — name it and the value.
Answer
satisfy any; — allowing either gate to pass the request (§3's
/satisfy-any/ is exactly this shape).
Q4. A teammate "simplifies" a protected location with
return 200 "ok"; and the security review finds unauthenticated access. Explain the mechanism in two sentences.
Answer
return executes in the rewrite phase, which runs before the access phase where auth_basic/allow/deny enforce — so the response is generated and the request completed without ever consulting the gate. The gate must stand in front of a content handler (static, proxy, try_files), not behind a short-circuit.
Takeaways
auth_basic+ htpasswd: the honest perimeter tool — TLS-only, hot-reloadable file, no app changes. Not an identity system.allow/deny: first-match-wins ordered rules over the (real, not proxied) peer address — compose the office/VPN pattern, and mindreal_ipwhen fronted.satisfy any|allcomposes the gates; the four-cell truth table is now muscle memory.Rewrite phase precedes access phase —
returnbypasses gates; gates guard handlers. The trap from §4, still biting real configs today.The escalation path is
auth_request(Module 11) → token validation (Lesson 15.4) → mTLS — perimeter, identity, authorization, each at its natural layer.
Module 6 complete. Your edge now encrypts, refuses the obsolete, authenticates both directions, resumes sessions cheaply, and gates access by network and password — every claim verified live. Next: Module 7 — traffic control: rate limiting, connection caps, geo/map-driven policy, and surgical request blocking.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.