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

Levels: Advanced ~40 min read

Advanced

What you will learn

  1. Signed, expiring URLs with secure_link / secure_link_md5 — the recipe, the signature ingredients, and the failure vocabulary.

  2. Why URL signatures exist (CDNs, emails, S3-style links) and what they deliberately can't do.

  3. X-Accel-Redirect: the app authorizes, NGINX serves from an internal vault.

  4. The header-survival gotcha that bites first deployments (real receipt).

  5. The combined pattern: signed URL → gate → vault, as production runs it.


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:

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

bash
printf '%s' "<expires><uri><remote_addr> <secret>" \
  | openssl dgst -md5 -binary \
  | openssl base64 | tr '+/' '-_' | tr -d '='

The real receipt (link valid for one hour):

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

$secure_link

Meaning

Your response

""

hash absent or mismatched

403 — possible forgery

"0"

hash valid but expiry in the past

410 Gone — "it was real, get a new one" (the client's cache/refresh logic deserves the distinction)

"1"

valid and current

serve

And the three outcomes (real captures):

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

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

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

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

text
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_addr from both the config and the recipe and watch it work from 127.0.0.2 too.

  • 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_link verifies URL-embedded signatures: secure_link (tristate) + secure_link_md5 (the recipe) — and the recipe's base64 is URL-safe, unpadded.

  • Ingredients are policy: expires bounds life, $uri binds the path, $remote_addr binds the client (drop it to share), the secret is the root of trust.

  • X-Accel-Redirect splits authorization (app) from byte-serving (NGINX's static handler: sendfile, ranges, Last-Modified) — with the vault internal so outside hits are honest 404s.

  • App headers survive the internal redirect — set Content-Type/ Content-Disposition deliberately in the gate, or inherit the app's defaults (our shipped-then-fixed receipt).

Was this page helpful?

Mark this learning resource complete to track your learning progress.