Some resources cannot run naked on the public internet: paid-download files and time-limited shared archives all need “a valid credential to access”. Nginx’s secure_link module (ngx_http_secure_link_module) achieves this with URL signing — a request passes only if it carries the correct signature and an unexpired validity parameter; otherwise it’s rejected outright. Whether the signature checks out and how expiration is decided are easy to get wrong from the docs alone; this post tests everything live.

Organized as a hands-on config walkthrough: first the lab environment and signature generation, then verifying the actual outcome of five kinds of requests — allowed, expired, unsigned, tampered, and different origin. All output comes from real runs on nginx/1.31.5.

Lab Environment

  • nginx/1.31.5 (docker image nginx:alpine; nginx -V confirms it ships with --with-http_secure_link_module);
  • Protected file /usr/share/nginx/html/private.zip (content SECRET);
  • Signatures generated by a backend script — in this experiment a piece of Python plays that role (really runnable):
1
2
3
4
5
6
7
import hashlib, base64, time

expires = int(time.time()) + 300 # expires in 5 minutes
raw = f"{expires}/private.zip127.0.0.1 secret_key" # expiry time+URI+client IP+space+key
sig = base64.urlsafe_b64encode(
hashlib.md5(raw.encode()).digest()).rstrip(b"=").decode()
print(f"/private.zip?md5={sig}&expires={expires}")

Watch the encoding detail: the MD5 digest is encoded as base64url (+→-, /→_) with the trailing = padding stripped — standard base64 will never match the signature.

Who This Defense Stops

Two typical scenarios, matching the two uses of the signature:

  • Anti-hotlinking: the signature and expiry are issued by your backend after the user completes a purchase/login; third-party sites can’t get the key and can’t forge the signature, so every request linked from their pages gets 403 — your bandwidth won’t be freeloaded by other sites. Combined with the IP binding tested above, it even stops “the link being copied somewhere else”.
  • Temporary links: the signature carries an expiry time and invalidates automatically once past due — good for time-limited file sharing and keeping download URLs from spreading. A link that has expired returns 410, with no need for the backend to maintain any “revocation list” — the invalidation logic lives entirely in the URL itself, which is the most maintenance-free aspect of secure_link compared to the “store tokens in a database” approach. A side note on the semantic division between 410 and 403: 410 Gone tells the client “this was once valid, now it’s dead”, a clear signal to a client “retrying repeatedly with an expired link”; 403 means “never legal in the first place”. Both block the request, but the way the frontend guides the user differs completely — for expired, guide them to re-apply; for 403, what needs checking is the link distribution chain.

Its boundary must also be stated clearly: the signature protects “the legitimacy of the URL”, not “the confidentiality of the file content” — whoever holds a valid link can still download the complete file within its validity. Content-level confidentiality is the job of HTTPS transport encryption and application-layer encryption.

Configuration

Protecting /private.zip:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server {
listen 80;

location / {
root /usr/share/nginx/html;
}

location /private.zip {
root /usr/share/nginx/html;
secure_link $arg_md5,$arg_expires;
secure_link_md5 "$secure_link_expires$uri$remote_addr secret_key";

if ($secure_link = "") {
return 403;
}

if ($secure_link = "0") {
return 410;
}
}
}

Line by line:

  • secure_link $arg_md5,$arg_expires;: takes the signature and expiry from the URL’s md5 and expires parameters.
  • secure_link_md5 "...": defines the concatenated string for the signature algorithm — expiry time, request URI, client IP, plus your own secret_key, and the MD5 is computed over this string. Whatever goes into the concatenation string, the client-side link generation must assemble in exactly the same order.
  • The verification result lands in $secure_link: empty string when the signature doesn’t match, "0" when the signature matches but has expired, "1" when both pass. The two ifs map the first two cases to 403 and 410 Gone respectively; on success, the inherited root serves the file directly.

Note: the original example had one more line after the two ifs — rewrite ^/(.*)$ /$1? permanent; — which would 301 the verified request to the same URL stripped of its signature parameters, guaranteeing the verification fails again after the redirect, forming a “passes-then-dies” loop; it was removed in restructuring — once verification passes, the inherited root serving the file directly is all you need.

Live Test: How Five Kinds of Requests Actually Fare

Construct URLs with the generated signature and fire one shot for each of the five cases:

Figure 1

Reading the figure, three main lines — correct signature and unexpired: 200 with the file; correct signature but past expiry: 410; no signature: 403. Note that the 410 judgment uses the expiry time filled in when the signature was generated: Nginx recomputes the signature from the request’s expires parameter, and the two values must match as a pair. This leads to an easy-to-mistest pitfall:

Common mis-test: taking a valid signature and directly editing the expires parameter in the URL to see the “expired” effect yields not 410 but 403 — because expires is one of the inputs to the signature computation, and changing it amounts to tampering with the signature (confirmed by testing). To test expiration, the script must regenerate a whole new set of signature and parameters using “a timestamp already in the past”.

The anti-hotlinking dimension was tested live too: changing the IP inside the signature to another machine (same URL requested from a different source IP) — because the concatenation string contains $remote_addr, signature verification fails immediately:

Figure 2

The link dies when moved to another machine — both a hardening and a limitation: watch out when traffic passes through proxy chains that rewrite the source IP; if necessary, remove $remote_addr from the concatenation string, trading “link not bound to a person” for compatibility.

Caveats

  • Confirm the module is available first with nginx -V | grep secure_link: the official docker image compiles it in by default (1.31.5 tested as included); only source-built nginx needs --with-http_secure_link_module added.
  • Once secret_key leaks, anyone can forge signatures; keep it server-side only and rotate it regularly. The signature algorithm is MD5 — its security rests entirely on key secrecy, not algorithmic strength.
  • The expiry time is a Unix timestamp (seconds); expired links return 410, and the frontend can guide users facing such responses to re-apply for the download.
  • The clock is the invisible prerequisite of this mechanism. expires is judged against the server’s local time; if the business machine issuing signatures and the machine running Nginx drift tens of seconds apart, eerie phenomena appear like “a freshly issued link gets 410 right away”; putting both machines on NTP is a fixed pre-launch step.
  • Key rotation must account for links already issued. The instant you swap secret_key, all existing signatures die — every download link already handed out fails with 403 in bulk. The smooth approach is to keep both old and new verification during the rotation window (two locations, one key each; new links get new signatures, and the old key is retired after old links naturally expire).
  • To issue links with different validity periods to different users, add a user identifier to the secure_link_md5 concatenation string — the signature’s “anti-forgery scope” follows the concatenation string.

Summary

Back to the starting point of “resources don’t run naked”: one secure_link config turns “can I download this” into “are you within the validity period, from the bound IP, carrying the correct signature”. The most valuable two insights from the live tests of five request kinds: the expires parameter is a signature input — changing it isn’t expiration, it’s tampering; and every variable in the concatenation string is part of the signature — adding $remote_addr nails the link to a single origin. Before going live, verify all three outcomes — 200/410/403 — with a signature script; that beats staring at logs and guessing afterward.


This post was reconstructed from the author’s CSDN blog articles written between 2020 and 2024; it was originally published on CSDN.