11.5 Signed URLs & Protected Downloads
Two production workhorses, one theme: NGINX and the application each do the half they’re best at. secure_link puts trust in the URL; X-Accel-Redirect puts the bytes back in NGINX’s hands.
Advanced
What you will learn
Signed, expiring URLs with
secure_link/secure_link_md5— the recipe, the signature ingredients, and the failure vocabulary.Why URL signatures exist (CDNs, emails, S3-style links) and what they deliberately can't do.
X-Accel-Redirect: the app authorizes, NGINX serves from aninternalvault.The header-survival gotcha that bites first deployments (real receipt).
The combined pattern: signed URL → gate → vault, as production runs it.
1.
secure_link — trust embedded in the URL
The problem shape: you want to email a customer a link to their invoice that (a) works without a login session, (b) expires in 24 hours, and (c) can't be forged or shared beyond its scope. The answer is a signature over the link's own facts:
location /files/ {
secure_link $arg_md5,$arg_expires;
secure_link_md5 "$secure_link_expires$uri$remote_addr shared-secret";
if ($secure_link = "") { return 403 "bad signature\n"; }
if ($secure_link = "0") { return 410 "link expired — get a fresh one\n"; }
alias www/main/docs/; # /files/handbook.pdf -> docs/handbook.pdf
}The signature recipe (this is the part you'll grep for at 2 a.m. — it must match the config exactly, and it uses base64url without padding):
printf '%s' "<expires><uri><remote_addr> <secret>" \
| openssl dgst -md5 -binary \
| openssl base64 | tr '+/' '-_' | tr -d '='The real receipt (link valid for one hour):
printf '%s' "1790129258/files/handbook.pdf127.0.0.1 shared-secret" | \
openssl dgst -md5 -binary | openssl base64 | tr '+/' '-_' | tr -d '='
-> oo8VMjA5_HGBckfM3aNOlQ
GET /files/handbook.pdf?md5=oo8VMjA5_HGBckfM3aNOlQ&expires=1790129258 -> 200
The decision table —
$secure_link is a tristate, and each state has a job:
| Meaning | Your response |
|---|---|---|
| hash absent or mismatched |
|
| hash valid but expiry in the past |
|
| valid and current | serve |
And the three outcomes (real captures):
valid: 200 (signature + future expiry)
expired: 410 (signature good, time past — body: "link expired — get a fresh one")
garbage: 403 (forged or malformed)
The ingredients are policy. Our example signs
expires + uri + remote_addr + secret:
expires— links die on schedule, even from an inbox.$uri— the signature is path-bound; can't be reused on another file.$remote_addr— the strongest clamp: link only works from the IP that minted it. Drop it (and its/in the recipe) for links that must travel — that's your shareability dial.the secret — lives only on the minting service and NGINX; rotate by versioning (
shared-secret-v2).
What signatures deliberately
can't do: revoke before expiry (add the link to a
map blacklist if you must), bind to a user identity without more ingredients, or survive secret rotation (grace-mint with both secrets during the window).
The 401/403
ifs here are Lesson 11.2's sanctioned pattern A —
secure_link computes its variables in exactly the phase where
if { return } is safe.
2.
X-Accel-Redirect — the app authorizes, NGINX serves
The sibling problem: files that need application logic to authorize (session, quota, entitlement) but shouldn't stream through PHP/Python/Ruby to do it. X-Accel-Redirect splits the job:
client ──GET /download/handbook.pdf──> NGINX ──fastcgi──> PHP gate
client <─────── file bytes ────────── NGINX <─X-Accel-Redirect: /protected-files/handbook.pdf─┘
(internal jump; PHP exits with empty 200)The two pieces:
location /download/ {
root www/php;
rewrite ^/download/(.*)$ /accel.php break; # aim the FastCGI
include fastcgi_params; # handler at the gate
fastcgi_pass 127.0.0.1:9020;
}
location /protected-files/ {
internal; # ONLY reachable by internal redirect
alias www/main/docs/; # the vault's real path — never revealed
}<?php // accel.php — the gate: owns the decision, not the bytes
$session = $_GET['session'] ?? '';
if ($session !== 'sess-ok') { http_response_code(403); exit("403 — a valid ?session= is required\n"); }
header('Content-Type: application/pdf'); // load-bearing (Section 3)
header('X-Accel-Redirect: /protected-files/handbook.pdf');
exit;The
break-rewrite is Lesson 11.1 doing real work: it points
SCRIPT_FILENAME (built by
fastcgi_params as
$document_root$fastcgi_script_name) at
accel.php while
REQUEST_URI/
QUERY_STRING keep the client's original values — the gate sees what was really asked for.
The receipts (real captures):
--- no session ---
403 — a valid ?session= is required (try sess-ok)
status=403
--- with session ---
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 162
Last-Modified: Tue, 22 Sep 2026 18:54:41 GMT <- static serving kicked in
--- the vault, hit directly ---
status=404 <- internal: to the outside it "does not exist"Note
Last-Modified and byte-range support appearing — the file is served by NGINX's
static handler with all its Module 3 superpowers (sendfile, ranges, caching headers), not tunneled through PHP. This is why X-Accel-Redirect is the standard answer to "PHP downloads are eating my workers."
3. The classic first-run gotcha
On a first run of this pattern: the PDF arrived as
Content-Type: text/html; charset=UTF-8. Why?
The upstream's response headers survive the internal redirect. PHP, by default, had already sent its own
Content-Type: text/html — and when NGINX jumped to
/protected-files/ to serve the bytes, the header stayed. The fix is the
header('Content-Type: application/pdf'); line in the gate — the
application declares what it's handing over, NGINX provides the bytes. (The nginx-side alternative —
fastcgi_hide_header Content-Type; in the gate — works but hides it for
every response from that location; explicit beats clever.)
Generalize the lesson:
X-Accel-Redirect carries the app's headers through —
Content-Disposition,
Content-Type,
Cache-Control set by the gate all reach the client. Set them deliberately; they are part of the handoff contract, not NGINX's to guess.
4. The combined pattern (how production runs it)
Signed URL or session gate is rarely the endgame — they compose:
1. app mints: /files/invoice-9.pdf?md5=…&expires=… (secure_link shape)
2. NGINX verifies signature -> Lesson 11.5 §1
3. gate still checks entitlement -> X-Accel-Redirect to the vault
4. NGINX streams bytes (sendfile/ranges/cache headers)The CDN variant adds one wrinkle: sign the URL with a CDN-shared secret so the edge can verify without calling home — which is exactly what
secure_link was born for (it's the poor ops team's signed S3/CloudFront URL).
Try it yourself
curl -i http://127.0.0.1:9305/files/handbook.pdf— the unsigned contrast: no signature, no service. Then walk the tristate from §1 (valid 200, expired 410, garbage 403) and the gate from §2 (403 without a session, 200 + headers with one, and the vault's honest 404).Mint your own link:
date +%s+ 300 for a 5-minute URL; try it after lunch. Then drop$remote_addrfrom both the config and the recipe and watch it work from127.0.0.2too.Prove the vault's silence:
curl -i http://127.0.0.1:9305/protected-files/handbook.pdf→ 404, no hints.
Q&A
Q: MD5 in 2026 — seriously? A: The threat here is
forgery without the secret, not collision resistance — finding an md5 collision doesn't help you without knowing
shared-secret. The real secrets discipline: long, random, rotated. (And if your security review still objects: the same pattern with HMAC-SHA lives in njs or an auth_request validator — Module 15's toolkit.)
Q: Can the gate point at a
proxy_pass
app instead of PHP? A: Yes — X-Accel-Redirect works from
any upstream (proxy, fastcgi, uwsgi). The header is read from the upstream response; the rest is identical.
Q: What's the difference from
internal
-only redirects like
error_page
? A:
internal blocks
outside entry; X-Accel-Redirect is the
sanctioned inside trigger — the app's way of saying "serve this URI on my behalf." Same flag, opposite ends of the leash.
Q: Range requests / resumable downloads? A: Free — the static handler serves the vault, so
Range works through the redirect. Verify with
curl -H 'Range: bytes=0-9'.
Takeaways
secure_linkverifies URL-embedded signatures:secure_link(tristate) +secure_link_md5(the recipe) — and the recipe's base64 is URL-safe, unpadded.Ingredients are policy:
expiresbounds life,$uribinds the path,$remote_addrbinds the client (drop it to share), the secret is the root of trust.X-Accel-Redirectsplits authorization (app) from byte-serving (NGINX's static handler: sendfile, ranges, Last-Modified) — with the vaultinternalso outside hits are honest 404s.App headers survive the internal redirect — set
Content-Type/Content-Dispositiondeliberately in the gate, or inherit the app's defaults (our shipped-then-fixed receipt).
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.