Presigned URLs
Time-limited, HMAC-signed download links via nginx secure_link, validated inline by nginx with no application code on the serving path.
Info
The /_/ prefix is reserved for nginx-level helpers and sits outside the blob namespace, so it can never collide with a stored key.
How it works
The signing app computes an MD5 token over {expires}&{method}&{c}&{d}&{f}&{uri} {secret}, where c/d/f are the optional signed response-header args (empty when unused). Token, expiry and key id travel as single-letter query params, ?k=<key id>&e=<expiry>&t=<token>, plus &c=/&d=/&f= when set. nginx looks the secret up by ?k= (the same keys files as the two-header auth), recomputes the hash and answers 403 on a mismatch. Each issuer signs with its own secret; none is baked into the config.
The & separators are load-bearing: nginx's arg parser stops at &, so no $arg_* can contain one, and that is what keeps the concatenation unambiguous. Glued together, c=text/html + d=attachment would hash like c=text/htmlattachment + no d, letting a recipient strip the signed Content-Disposition off a valid token. $uri goes last because the decoded path may itself contain &.
The token is opaque and carries no identity; ?k= only selects the secret. The format is whatever secure_link_md5 says: any request-time variable ($remote_addr, $http_x_forwarded_for, $arg_*) can be bound, as long as signer and config agree byte for byte and adjacent client-supplied fields stay &-separated.
Nginx config
The shipped config binds method + path + expiry; IP binding is opt-in. /_/dl/ must bypass the api-key gate, which the $deny map's carve-out does.
# keys/presign.nginx.conf
# ?k= → secret used for HMAC verification
map $arg_k $key_secret {
default "";
"PUTFS_abc123" "secret1";
"PUTFS_xyz789" "secret2";
}
# keys/auth.nginx.conf (excerpt – the $deny gate)
map "$uri:$key_ok:$auth_ok" $deny {
default 1;
"~^/_/dl/" 0; # bypass: HMAC validates in the /_/dl/ location
"~:1:1$" 0; # $key_ok and $auth_ok both passed
}
# At http level: the key maps and the $cors_origin map the CORS snippets use.
include /etc/putfs/keys/*.nginx.conf;
include /etc/putfs/cors.nginx.conf;
# In the server block:
if ($deny) { return 403; }
location ^~ /_/dl/ {
# Preflight first: an OPTIONS can never carry a valid token (the HMAC
# binds $request_method). Paired with the cors include at the bottom.
include /etc/putfs/snippets/cors-preflight.nginx.conf;
# An unknown ?k= leaves no secret in the hash. The failure branches repeat
# the hardening headers (an `if` scope blocks add_header inheritance) and
# never echo $arg_d.
if ($key_secret = "") {
add_header X-Content-Type-Options 'nosniff' always;
add_header Content-Security-Policy 'sandbox' always;
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary 'Origin' always;
return 403;
}
secure_link $arg_t,$arg_e;
# Must match the signer byte for byte, `&` separators included.
secure_link_md5 "$secure_link_expires&$request_method&$arg_c&$arg_d&$arg_f&$uri $key_secret";
# Same 403 for forged ("") and expired ("0").
if ($secure_link != "1") {
add_header X-Content-Type-Options 'nosniff' always;
add_header Content-Security-Policy 'sandbox' always;
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary 'Origin' always;
return 403;
}
add_header Content-Disposition $arg_d always;
add_header X-Content-Type-Options 'nosniff' always;
add_header Content-Security-Policy 'sandbox' always;
include /etc/putfs/snippets/cors.nginx.conf;
alias /srv/putfs/;
sendfile on;
}
^~ makes /_/dl/* win over any regex location. The result:
- Time-limited: the token expires after the TTL
- Path-bound: it works for one file only
- Method-bound: a GET token cannot DELETE
- Per-key secret: nginx selects the secret via
?k=
Optional IP binding
The shipped config does not bind tokens to the client IP: behind docker's NAT, a load balancer or a CDN, $remote_addr is an address the signer cannot predict, and every token would 403. Add it when connections terminate directly (or a trustworthy $http_x_forwarded_for chain exists). Two changes that land together:
# At http level, next to the other maps:
map $remote_addr $presign_ip {
default $remote_addr;
}
# In location ^~ /_/dl/: $presign_ip goes between the signed args and $uri
secure_link_md5 "$secure_link_expires&$request_method&$arg_c&$arg_d&$arg_f&$presign_ip&$uri $key_secret";
Unset (the default), the client emits no field at all, which is what matches the shipped config: with &-separated fields, an omitted slot is not an empty one. A bound token only works from the client it was minted for; the cost is that a client whose address changes mid-session (mobile roaming, a proxy pool) loses its links.
Generate URL
The PutFS Python client is the reference signer:
from putfs.client.fs import PutFSFileSystem
fs = PutFSFileSystem(https=True)
url = fs.sign(
"putfs://putfs.example.com/invoices/q1.pdf",
expiration=30,
key="PUTFS_abc123",
secret="secret1",
args={ # every signed slot, config order
"c": "",
"d": "attachment;filename=q1.pdf",
"f": "",
},
)
args is an ordered mapping of query arg to value, hashed and appended to the URL in iteration order, so it maps one-to-one onto the $arg_* of your secure_link_md5. The shipped config signs $arg_c&$arg_d&$arg_f, so the full mapping, and the default, is {"c": …, "d": …, "f": …}. Name every slot the config signs in the config's order: an empty value hashes as the empty string, exactly as nginx reads an absent arg, but a left-out slot drops its separator and the token no longer matches. A secure_link_md5 that signs non-arg variables ($http_user_agent, say) is covered by the payload kwarg, which replaces the whole method+args slice (join its fields with & too).
Signing from another language is a dozen lines:
import hashlib, base64, time
from urllib.parse import quote
def presign(
path: str,
key: str,
secret: str,
method: str = "GET",
ttl: int = 30,
content_type: str = "",
content_disposition: str = "",
filename: str = "",
) -> str:
expires = int(time.time()) + ttl
# $arg_* are the raw, percent-encoded values: hash that exact form.
# add_header echoes it verbatim, so every byte encoded here reaches the
# browser as %XX; keep /=; (attachment;filename=...) and *' (RFC 5987
# filename*=UTF-8''...) literal.
c = quote(content_type, safe="/=;*'")
d = quote(content_disposition, safe="/=;*'")
f = quote(filename, safe="/=;*'")
# `path` is the raw key: nginx's $uri is the decoded path.
raw = f"{expires}&{method}&{c}&{d}&{f}&/_/dl{path} {secret}"
token = base64.urlsafe_b64encode(
hashlib.md5(raw.encode()).digest()
).decode().rstrip("=")
qs = f"k={key}&e={expires}&t={token}"
for name, value in (("c", c), ("d", d), ("f", f)):
if value:
qs += f"&{name}={value}"
# The key is sent encoded exactly once, or a %XX, # or ? in it
# addresses another file (and fails the hash).
return f"https://putfs.example.com/_/dl{quote(path, safe='/')}?{qs}"
# PDF viewer link, 30 seconds, GET only
url = presign("/invoices/q1.pdf", key="PUTFS_abc123", secret="secret1", ttl=30)
# Same blob, forced download with a custom filename
url = presign(
"/invoices/q1.pdf",
key="PUTFS_abc123",
secret="secret1",
content_disposition="attachment;filename=q1-invoice.pdf",
)
Empty optional args are still hashed as empty strings, and every set arg is in both the hash and the query string; nginx recomputes the whole concatenation.
Optional response-header override
Three signed query params carry response headers: d (content-disposition), c (content-type) and f (filename). When d is present, nginx echoes it as Content-Disposition, which forces attachment;filename=... on downloads or inline on viewer URLs without a second copy of the blob. All three are in the HMAC, so &d=… cannot be appended to someone else's token. Empty or absent means no header (nginx skips empty add_header values).
Stock-nginx caveats:
- Values must be ASCII-safe. nginx cannot URL-decode, so
$arg_dis sent verbatim; a space in a filename arrives as a literal%20. For RFC 5987 encoding, use openresty +ngx.unescape_uri. - No
Content-Typeoverride. Stock nginx cannot set it from a variable; rely on the file extension.cis signed so a deployment that adds the plumbing (openresty, headers-more) needs no re-sign, but the shipped config ignores it. fis signed, not applied. Put the filename insided(attachment;filename=q1.pdf);fis for deployments that compose the header themselves.
Dedicated presign-only keys
A key used only for signing is listed in $key_secret alone, not in $key_ok or $auth_ok:
map "$http_x_api_key:$http_x_api_secret" $key_ok {
default 0;
"PUTFS_abc123:secret1" 1; # full API key
# presign-key NOT listed → 403 if presented as a header
}
map "$http_x_api_key:$request_method:$uri" $auth_ok {
default 0;
"~^PUTFS_abc123:[^:]+:/acme/" 1;
# presign-key NOT listed
}
map $arg_k $key_secret {
default "";
"PUTFS_abc123" "secret1"; # can do both
"presign-key" "presign-secret"; # signing only
}
$key_secret is consulted only in /_/dl/, which is intrinsically GET under /_/dl/, so a leaked presign secret mints signed URLs but cannot PUT, DELETE or list. Sub-prefix scope per key is the signing app's job, unless you add the parallel map below.
Why one status for every failure
Expired tokens, forged tokens, missing api-key headers, wrong secrets and out-of-scope requests all answer 403. Distinguishing them (410 expired, 401 missing creds) leaks one bit per probe, letting an attacker learn whether a guess hit a real key. The api-key auth follows the same rule.
HMAC verifies signing power, not authorisation scope
A valid HMAC proves the URL was signed by a holder of the ?k= secret, not that the key may access the path under $auth_ok; the signing app is trusted to sign only what the user may see. For defence in depth, add a parallel map keyed on $arg_k:$request_method:$uri and check it inside /_/dl/, after the preflight include (an OPTIONS matches no :GET: entry):
Typical use case
A web app that shows PDFs:
- The user authenticates with the app (session, JWT, whatever)
- The user requests a document
- The app mints a presigned URL (30s TTL, GET only)
- The browser loads the PDF via that URL
- nginx serves the file via
sendfile: no auth service call, no Python
A leaked link works for GET on that one path until it expires; add IP binding to tie it to the client too.
vs AWS presigned URLs
| nginx secure_link | AWS presigned URL | |
|---|---|---|
| Hash | HMAC-MD5 | HMAC-SHA256 |
| IP binding | opt-in ($remote_addr) |
IAM condition only |
| Method binding | built-in ($request_method) |
signed into URL |
| Claims/identity | API key in URL (selects secret) | access key ID |
| Serving latency | nginx inline (~0) | S3 endpoint |
Why not mimic SigV4 for regular auth?
Signing every API request with secure_link would keep the secret off the wire, but TLS already gives the two-header auth model the same confidentiality and replay protection. secure_link stays for what it is good at: time-limited, method- and path-bound download tokens for untrusted clients. Header auth is simpler and much faster for the API path.
Further reading
- nginx
secure_link: stock module (HMAC-MD5) ngx_http_hmac_secure_link_module: third-party HMAC-SHA256/SHA1, a drop-in forsecure_link_md5with longer tokens and length-extension safety; needs a custom nginx build