Skip to content

Presigned URLs

Time-limited, HMAC-signed download links via nginx secure_link. Validated inline by nginx – zero application code in the serving path.

Info

The /_/ prefix is reserved for nginx-level helpers (presigned URLs and similar) – it sits outside the regular blob namespace so it can never collide with a stored key.

How it works

The generating app computes an HMAC-MD5 token from {expires}&{method}&{c}&{d}&{f}&{uri} {secret}, where c/d/f are the optional signed response-header args (empty when unused). The & separators are not cosmetic: no $arg_* can contain one, so they are what stops a token from being replayed with its bytes re-partitioned across the adjacent args. The token, expiry, and API key go into query params, all single-letter: ?k=<key id>&e=<expiry>&t=<token> plus &c=/&d=/&f= when set. nginx looks the secret up by ?k= (same keys.conf source as the two-header API auth), recomputes the hash, and compares – if they don't match, 403. No shared <secret> is baked into nginx config; each issuer signs with its own.

The token itself is opaque – it carries no user identity. The ?k= param only tells nginx which secret to verify against; identity is still established out-of-band by the signing app.

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, and that adjacent client-supplied fields stay separated by a byte none of them can contain (& for query args). This makes it easy to adapt the binding to your needs – e.g. bind tokens to the client IP (see below), add a custom header, or scope tokens to a specific query parameter.

Nginx config

Recommended: bind to method + path + expiry. The shipped config does exactly that; IP binding is an opt-in addition.

Presigned URLs are meant for clients that don't hold the api-key/secret, so /_/dl/ must bypass the global api-key auth. The single $deny map (defined alongside $key_ok / $auth_ok) carves out /_/dl/ so HMAC validation in the location takes over.

# 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)
#   ~^/_/dl/   → 0 (bypass: HMAC validates in /_/dl/ location)
#   ~:1:1$     → 0 ($key_ok and $auth_ok both passed)
#   default    → 1 (deny)
map "$uri:$key_ok:$auth_ok" $deny {
    default          1;
    "~^/_/dl/"       0;
    "~:1:1$"         0;
}
# At http level – the key maps (see auth.md) and the $cors_origin map
# both CORS snippets dereference (contrib/cors.nginx.conf). Without the
# maps, nginx refuses to start: [emerg] unknown "..." variable.
include /etc/putfs/keys/*.nginx.conf;
include /etc/putfs/cors.nginx.conf;

# In the server block:
if ($deny) { return 403; }

location ^~ /_/dl/ {
    # CORS preflight FIRST, before any credential check. A preflight
    # OPTIONS carries no token – the browser sends it before the signed
    # request, and the HMAC binds $request_method, so it could never
    # validate. Checking credentials first would 403 every preflighted
    # fetch (the CORS spec requires a 2xx).
    include /etc/putfs/snippets/cors-preflight.nginx.conf;

    # Missing/unknown ?k= → $key_secret is "" → the secure_link hash
    # contains no secret, so anyone can forge a valid token. Reject before
    # secure_link runs (see the note after this block). Both failure
    # branches declare their own headers: nginx `if` scopes stop
    # add_header inheritance, so a 403 never echoes the attacker-supplied
    # $arg_d emitted below for successful requests.
    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;
    # Field order must match the signer byte for byte, `&` separators
    # included: nginx's own arg parser stops at `&`, so no $arg_* can hold
    # one, and that is what keeps the concatenation unambiguous. Glued
    # together, $arg_c$arg_d$arg_f are interchangeable and a valid token
    # replays with the bytes shifted between them. $uri goes last because
    # it is the decoded path and may itself contain a literal `&`.
    secure_link_md5  "$secure_link_expires&$request_method&$arg_c&$arg_d&$arg_f&$uri $key_secret";

    # Single 403 covers both forged ("") and expired ("0") tokens – don't
    # let an attacker distinguish "this token was never valid" from "this
    # token used to be valid". See "Why one status" below.
    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;
    }

    # Optional response-header override (signed, see below).
    add_header Content-Disposition $arg_d always;
    # Stored-XSS hardening – see the Serving reference.
    add_header X-Content-Type-Options 'nosniff' always;
    add_header Content-Security-Policy 'sandbox' always;
    # CORS response headers – the counterpart of the preflight include at
    # the top (makes the actual response readable cross-origin and exposes
    # Content-Range/Accept-Ranges/Content-Disposition); wire both includes
    # or neither.
    include /etc/putfs/snippets/cors.nginx.conf;

    alias /srv/putfs/;
    sendfile on;
}

If ?k= is missing or unknown, $key_secret resolves to "" (if not set a default secret). The if ($key_secret = "") { return 403; } guard at the top of the location rejects those requests before secure_link is consulted.

The ^~ modifier on the location ensures /_/dl/* wins over the regex listing location (~ /$) for any path starting with /_/dl/.

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
  • Per-key secret – nginx selects the secret via ?k=; no shared <secret> baked into the config

Optional IP binding

The shipped config does not bind tokens to the client IP, because the value nginx sees is only the real client IP when nothing NATs or proxies in front of it – behind docker's host<->container NAT, a load balancer, or a CDN, $remote_addr is an address the signer cannot predict, and every token 403s. Add it deliberately, when you know your deployment terminates connections directly (or you have a trustworthy $http_x_forwarded_for chain).

It is two changes that must land together – an extra field in secure_link_md5, and the matching field in the signer:

# 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

The client's ip kwarg covers precisely the slice between the signed args and $uri, so it lines up with the field position above. Leave it unset (the default) and no field is emitted at all – which is what matches the shipped config: with the fields &-separated, an omitted slot is not the same as an empty one.

A token bound this way only works from the client it was minted for, so a leaked link is useless elsewhere. The cost is that any client whose source address changes mid-session (mobile roaming, a proxy pool) loses its links.

Generate URL

The PutFS Python client is the reference signer – PutFSFileSystem.sign() mints URLs in exactly this format:

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: the values are joined into the hash with & in iteration order and appended to the URL in that same order, so it maps one-to-one onto the $arg_* variables in your secure_link_md5. The shipped config signs $arg_c&$arg_d&$arg_f, so the full mapping is {"c": …, "d": …, "f": …} – and that is also the default. Name every slot your config signs: a value you set empty is hashed as the empty string, exactly as nginx reads an absent arg, but a slot you leave out drops its separator and the token no longer matches. Reorder the mapping and it no longer matches either, so keep it in the config's order. A deployment whose secure_link_md5 signs variables that are not query args at all (say $http_user_agent) can replace the whole method+args slice with the payload kwarg (join its fields with & too).

Signing from another language is a dozen lines – the format is a plain concatenation:

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
    # nginx's $arg_* are the raw, percent-encoded values – the hash must use
    # that exact form, and add_header echoes it verbatim, so anything encoded
    # here lands as a literal %XX in the response header. nginx ends an arg at
    # & and nothing else, so keep the other sub-delims literal: /=; for
    # `attachment;filename=…` and *' for RFC 5987's `filename*=UTF-8''…`.
    c = quote(content_type, safe="/=;*'")
    d = quote(content_disposition, safe="/=;*'")
    f = quote(filename, safe="/=;*'")
    # `&` separates the fields, including the empty ones: no $arg_* can
    # contain one, so the concatenation cannot be re-partitioned.
    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}"
    return f"https://putfs.example.com/_/dl{path}?{qs}"

# Example: PDF viewer link, 30 seconds, GET only
url = presign(
    "/invoices/q1.pdf",
    key="PUTFS_abc123",
    secret="secret1",
    ttl=30,
)

# Same blob, but force download with a custom filename
url = presign(
    "/invoices/q1.pdf",
    key="PUTFS_abc123",
    secret="secret1",
    content_disposition="attachment;filename=q1-invoice.pdf",
)

Optional args that are left empty must still be hashed as empty strings, and every arg that is set must be in both the hash and the query string – nginx recomputes the whole concatenation.

Optional response-header override

The signing format reserves three query params for response headers: d (content-disposition), c (content-type) and f (filename). When d is present, nginx echoes it as the Content-Disposition response header – useful for forcing attachment;filename=... on downloads or inline on viewer URLs without a separate copy of the blob.

All three are part of the HMAC, so an attacker can't take a legitimate ?t=… URL and append &d=… to alter the response. They are hashed &-separated for the same reason: without a separator the three are one blob and the same token validates with the bytes re-partitioned across them, so c=text/html&d=attachment could be replayed as c=text/htmlattachment with the signed Content-Disposition dropped. Empty/absent → no header emitted (nginx skips empty add_header values).

Stock-nginx caveats:

  • Value must be ASCII-safe (no spaces, quotes, or non-ASCII filenames). Stock nginx has no URL-decode primitive, so the raw $arg_d is sent verbatim as the header value. Spaces in filenames will appear as literal %20 in the header. For full RFC 5987-style encoding, use openresty + ngx.unescape_uri.
  • No Content-Type override. Stock nginx can't replace Content-Type from a variable; rely on the file extension. If the blob has the right extension (.pdf, .jpg, …) the right MIME type is served automatically. 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. The reference config emits d verbatim, so put the filename inside it (attachment;filename=q1.pdf). The separate f slot exists for deployments that compose the header themselves.

Dedicated presign-only keys

If a key is only ever used to sign presigned URLs (never for direct API calls), don't add it to $key_ok or $auth_ok at all – list it only in $key_secret:

# keys.conf

# Direct API access: regular keys only
map "$http_x_api_key:$http_x_api_secret" $key_ok {
    default                     0;
    "PUTFS_abc123:secret1"      1;   # full API key
    # presign-key NOT listed → 401 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 → 403 even if it slipped past $key_ok
}

# Single deny gate – /_/dl/ bypasses; otherwise both checks must pass
map "$uri:$key_ok:$auth_ok" $deny {
    default          1;
    "~^/_/dl/"       0;
    "~:1:1$"         0;
}

# Presigned URL signing: regular keys + dedicated presign-only key
map $arg_k $key_secret {
    default              "";
    "PUTFS_abc123"       "secret1";       # can do both
    "presign-key"        "presign-secret";  # signing only
}

The presign-only key/secret pair has no header-auth power: the /_/dl/ location is the only place $key_secret is consulted, and it is intrinsically scoped to GET (sendfile) under /_/dl/. If the secret leaks, the holder can mint signed URLs but cannot make direct PUT/DELETE/LIST calls. To narrow further (e.g. only certain prefixes under /_/dl/), restrict the path the signing app is willing to sign – nginx can't enforce sub-prefix scope per key without a parallel $auth_ok map keyed on $arg_k (see security note below).

Why one status for every failure

The same 403 covers expired tokens, forged tokens, missing api-key headers, wrong secrets, and out-of-scope requests. Distinguishing them (e.g. 410 for expired, 401 for missing creds) leaks one bit per probe – an attacker learns whether their guess hit a real signing key or a real api-key, and can grind. The api-key auth follows the same uniform-403 rule for the same reason.

HMAC verifies signing power, not authorisation scope

A valid HMAC proves the URL was signed by someone holding the secret for ?k=. It does not check that the key is authorised for the requested path under $auth_ok. The signing app is trusted to only sign URLs the user is entitled to. For defence in depth, add a parallel map keyed on $arg_k:$request_method:$uri and check it inside /_/dl/:

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

location ^~ /_/dl/ {
    # Preflight still first – an OPTIONS carries no api-key headers and
    # matches no :GET: entry above, so putting this check before the
    # preflight include would 403 every preflight (see Nginx config).
    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. User authenticates with the app (session, JWT, whatever)
  2. User requests to view a document
  3. The app generates a presigned URL (30s TTL, GET only)
  4. Browser loads the PDF via the presigned URL
  5. 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 for GET on that one path, and only until it expires – add IP binding to also tie it to the client it was minted for.

vs AWS presigned URLs

AWS presigned URLs use SigV4 (HMAC-SHA256) and encode the access key, expiry, and signed headers into query params. They can't bind to the client IP – access control is via IAM policies, not the URL itself.

nginx secure_link is simpler (no IAM, no SigV4 complexity) and can fold the method, and optionally the client IP, straight into 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 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?

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, method- and path-bound download tokens for untrusted clients – and use simple (and herefore much faster) header auth for the API path.

Further reading

  • nginx secure_link – stock module (HMAC-MD5, ships with every nginx build)
  • ngx_http_hmac_secure_link_module – third-party module with proper HMAC-SHA256/SHA1. Drop-in replacement for secure_link_md5 with longer tokens and length-extension safety. Worth considering if you issue long-TTL tokens or need AWS SigV4-grade hashing; requires a custom nginx build.