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}{uri}{client_ip} {secret}. The token, expiry, and API key go into query params. nginx looks the secret up by ?key= (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 ?key= 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. 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.

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
# ?key= → secret used for HMAC verification
map $arg_key $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;
}
# In the server block:
if ($deny) { return 403; }

location ^~ /_/dl/ {
    # Missing/unknown ?key= → $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).
    if ($key_secret = "") { return 403; }

    secure_link      $arg_token,$arg_expires;
    secure_link_md5  "$secure_link_expires$request_method$uri$remote_addr$arg_content_disposition $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") { return 403; }

    # Optional response-header override (signed, see below).
    add_header Content-Disposition $arg_content_disposition always;

    alias /srv/putfs/;
    sendfile on;
}

If ?key= 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
  • IP-bound – token only works from the client that requested it, can't be shared or leaked
  • Per-key secret – nginx selects the secret via ?key=; no shared <secret> baked into the config

Generate URL

import hashlib, base64, time
from urllib.parse import quote

def presign(
    path: str,
    key: str,
    secret: str,
    client_ip: str,
    method: str = "GET",
    ttl: int = 30,
    content_disposition: str = "",
) -> str:
    expires = int(time.time()) + ttl
    # nginx's $arg_content_disposition is the raw, percent-encoded value –
    # the hash must use that exact form. Keep ;= literal so the header
    # reads correctly downstream; everything else is percent-encoded.
    cd = quote(content_disposition, safe=";=")
    raw = f"{expires}{method}/_/dl{path}{client_ip}{cd} {secret}"
    token = base64.urlsafe_b64encode(
        hashlib.md5(raw.encode()).digest()
    ).decode().rstrip("=")
    qs = f"token={token}&expires={expires}&key={key}"
    if cd:
        qs += f"&content_disposition={cd}"
    return f"https://putfs.example.com/_/dl{path}?{qs}"

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

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

Optional response-header override

The signing format reserves content_disposition as a query param. When 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.

The param is part of the HMAC, so an attacker can't take a legitimate ?token=… URL and append &content_disposition=… to alter the response. 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_content_disposition 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.

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_key $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_key (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 ?key=. 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_key:$request_method:$uri and check it inside /_/dl/:

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

location /_/dl/ {
    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, user's IP, 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 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 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, IP-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.