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

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.

Levels: Intermediate ~35 min read

Intermediate

What you will learn

  1. auth_basic — the 30-year-old protocol that's still the right answer surprisingly often.

  2. allow/ deny — the access module's ordered rule list.

  3. satisfy any|all — composing the two gates.

  4. The phase-order trap: why a return inside a protected location bypasses auth entirely (real bug — reproduced in §4).

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

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

bash
$ 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'
401

The 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

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

bash
$ 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 → denied

The full vocabulary: single IPs, CIDR ranges ( 10.0.0.0/8), IPv6 ( 2001:db8::/32), all. And the classic composition idiom:

nginx
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 you

Two 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

nginx
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

satisfy any

satisfy all

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:

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

nginx
location /private/ {
    auth_basic …;
    proxy_pass http://127.0.0.1:9001/;    # static files / proxying /
}                                         # try_files — all post-access

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

  1. Reproduce the truth table (§3) — all four cells, with --interface 127.0.0.2 playing "the internet."

  2. 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.)

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

  4. 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 mind real_ip when fronted.

  • satisfy any|all composes the gates; the four-cell truth table is now muscle memory.

  • Rewrite phase precedes access phase — return bypasses 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.

Was this page helpful?

Mark this learning resource complete to track your learning progress.