Skip to content

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";
url = fs.sign(..., ip="203.0.113.42")   # fills exactly that slot

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_d is sent verbatim; a space in a filename arrives as a literal %20. For RFC 5987 encoding, use openresty + ngx.unescape_uri.
  • No Content-Type override. Stock nginx cannot set it from a variable; rely on the file extension. c is signed so a deployment that adds the plumbing (openresty, headers-more) needs no re-sign, but the shipped config ignores it.
  • f is signed, not applied. Put the filename inside d (attachment;filename=q1.pdf); f is 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):

map "$arg_k:$request_method:$uri" $presign_auth_ok {
    default                            0;
    "~^PUTFS_abc123:GET:/_/dl/acme/"     1;
    "~^presign-key:GET:/_/dl/"           1;
}

location ^~ /_/dl/ {
    include /etc/putfs/snippets/cors-preflight.nginx.conf;
    if ($presign_auth_ok != "1") { return 403; }
    # ... secure_link checks as above
}

Typical use case

A web app that shows PDFs:

  1. The user authenticates with the app (session, JWT, whatever)
  2. The user requests a document
  3. The app mints a presigned URL (30s TTL, GET only)
  4. The browser loads the PDF via that URL
  5. 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