Presigned URLs
Time-limited, HMAC-signed download links via nginx secure_link. Validated inline by nginx – zero application code in the serving path.
How it works
The generating app computes an HMAC-MD5 token from {expires}{method}{uri}{client_ip} {secret}. The token and expiry go into query params. nginx recomputes the hash from the actual request values and compares – if they don't match, 403.
The token is opaque. nginx can't extract claims from it – it just verifies yes/no. This is fine when the app controls token generation and already knows who the user is.
The hash format is entirely configurable via the secure_link_md5 directive. Any nginx variable available at request time can be included: $remote_addr, $request_method, $uri, $http_x_forwarded_for, $arg_*, etc. The only requirement is that the token generation code and the nginx config use the exact same format string. This makes it easy to adapt the binding to your needs – e.g. drop IP binding for mobile clients, add a custom header, or scope tokens to a specific query parameter.
Nginx config
Recommended: bind to method + path + client IP + expiry for maximum security.
location /dl/ {
secure_link $arg_token,$arg_expires;
secure_link_md5 "$secure_link_expires$request_method$uri$remote_addr <secret>";
if ($secure_link = "") { return 403; }
if ($secure_link = "0") { return 410; } # expired
alias /srv/putfs/;
sendfile on;
}
This ensures:
- Time-limited – token expires after TTL (e.g. 30 seconds)
- Path-bound – token only works for the specific file
- Method-bound – GET token can't be used for DELETE
- IP-bound – token only works from the client that requested it, can't be shared or leaked
Generate URL
import hashlib, base64, time
def presign(
path: str,
client_ip: str,
method: str = "GET",
secret: str = "secret",
ttl: int = 30,
) -> str:
expires = int(time.time()) + ttl
raw = f"{expires}{method}/dl{path}{client_ip} {secret}"
token = base64.urlsafe_b64encode(
hashlib.md5(raw.encode()).digest()
).decode().rstrip("=")
return f"https://putfs.example.com/dl{path}?token={token}&expires={expires}"
# Example: PDF viewer link, 30 seconds, GET only, locked to client IP
url = presign("/invoices/q1.pdf", client_ip="203.0.113.42", ttl=30)
Typical use case
A web app that shows PDFs:
- User authenticates with the app (session, JWT, whatever)
- User requests to view a document
- The app generates a presigned URL (30s TTL, user's IP, GET only)
- Browser loads the PDF via the presigned URL
- nginx serves the file directly via
sendfile– no auth service call, no Python
The link expires in 30 seconds. Even if leaked, it only works from that client's IP and only for GET.
vs AWS presigned URLs
AWS presigned URLs use SigV4 (HMAC-SHA256) and encode the access key, expiry, and signed headers into query params. They don't bind to client IP – access control is via IAM policies, not the URL itself.
nginx secure_link is simpler (no IAM, no SigV4 complexity) but can bind to client IP and method directly in the hash – something AWS presigned URLs can't do without additional IAM policy conditions.
| nginx secure_link | AWS presigned URL | |
|---|---|---|
| Hash | HMAC-MD5 | HMAC-SHA256 |
| IP binding | built-in ($remote_addr) |
IAM condition only |
| Method binding | built-in ($request_method) |
signed into URL |
| Claims/identity | none (opaque token) | access key ID |
| Serving latency | nginx inline (~0) | S3 endpoint |
Why not mimic SigV4 for regular auth?
We considered using secure_link to sign every API request – the client would pre-compute an HMAC token per request and nginx would validate it inline, similar to how AWS SigV4 works. The secret would never travel over the wire. In practice, this adds complexity without meaningful security gain: our auth model already sends key and secret over TLS headers, which provides the same confidentiality. SigV4-style signing protects against replay and tampering in transit, but TLS already does that. We keep secure_link for what it's good at – time-limited, IP-bound download tokens for untrusted clients – and use simple (and herefore much faster) header auth for the API path.