Skip to content

Authentication

Handled by nginx: map-based auth with no subrequest, or auth_request to an external service.

Map-based auth (nginx)

Two headers per request:

X-Api-Key:    PUTFS_abc123     ← identifies the client
X-Api-Secret: <secret>         ← authenticates the client

Two map lookups, resolved from nginx memory, no syscall, no subrequest:

X-Api-Key + X-Api-Secret           → $key_ok map  (1 = valid pair)
X-Api-Key + request_method + uri   → $auth_ok map (1 = in scope)

A third map, $deny, folds both plus the /_/dl/ presign carve-out into one gate. Every failure is the same 403; a distinct status for "wrong key" vs "wrong scope" would let an attacker probe for valid keys.

Matching runs on $uri, nginx's normalised, percent-decoded path (no query string, .. collapsed), so auth, try_files and the upstream all see one canonical path and %2F tricks cannot diverge them.

Keys file

# keys/auth.nginx.conf – edit, then nginx -s reload

# Authentication: key + secret, an O(1) hash lookup. nginx matches string
# keys case-insensitively; use hex secrets (see below).
map "$http_x_api_key:$http_x_api_secret" $key_ok {
    default                     0;
    "PUTFS_abc123:secret1"      1;
    "PUTFS_xyz789:secret2"      1;
}

# Authorisation: key + method + normalised URI in one regex
map "$http_x_api_key:$request_method:$uri" $auth_ok {
    default                                       0;
    "~^PUTFS_abc123:[^:]+:/acme/"                 1;   # acme, all methods
    "~^PUTFS_xyz789:(GET|HEAD|QUERY):/acme/invoices/"  1;   # read-only, invoices
}

# Single deny gate: 1 = 403, 0 = allow. /_/dl/ bypasses (HMAC validates
# inside the presign location); otherwise both maps must be 1.
map "$uri:$key_ok:$auth_ok" $deny {
    default          1;
    "~^/_/dl/"       0;
    "~:1:1$"         0;
}

In the server block:

if ($deny) { return 403; }

Key case-sensitivity

nginx map matches plain string keys case-insensitively, so a mixed-case secret loses the entropy in its case. Prefer hex keys and secrets (openssl rand -hex 32): no entropy in case, and the lookup stays one hash probe.

For case-sensitive matching (imported base64 secrets, say), make the entry a regex key: "~^\QPUTFS_abc123:sEcR3t+X/Y\E$" 1;. \Q...\E quotes metacharacters, ^...$ anchors. Regex keys are tried sequentially, which is irrelevant for a handful of keys and measurable for hundreds. $auth_ok uses regex keys anyway, so scopes are always case-sensitive.

Method patterns

Pattern Meaning
[^:]+ Any method
(GET\|HEAD\|QUERY) Read only (QUERY is listing)
(PUT\|DELETE) Write only
GET Download only, no listing

QUERY must be granted explicitly

Listing is QUERY /prefix/, not GET. A (GET|HEAD) scope still downloads but 403s on listing, with no other signal. Wildcard scopes are unaffected.

Scope examples

map "$http_x_api_key:$request_method:$uri" $auth_ok {
    default                                             0;

    # full dataset, all methods
    "~^PUTFS_acme_rw:[^:]+:/acme/"                      1;

    # full dataset, read only (QUERY = list)
    "~^PUTFS_acme_ro:(GET|HEAD|QUERY):/acme/"           1;

    # subfolder within dataset, all methods
    "~^PUTFS_invoices:[^:]+:/acme/invoices/"            1;

    # upload-only – PUT and DELETE, specific path
    "~^PUTFS_upload:(PUT|DELETE):/acme/uploads/"        1;

    # global admin – all methods, all paths
    "~^PUTFS_admin:[^:]+:/.+"                           1;
}

Key management

KEY_ID="PUTFS_$(openssl rand -hex 8 | tr '[:lower:]' '[:upper:]')"
SECRET="$(openssl rand -hex 32)"
echo "Key ID: $KEY_ID"
echo "Secret: $SECRET"
# Add "${KEY_ID}:${SECRET}" 1; to $key_ok and a scope line to $auth_ok, then:
nginx -t && nginx -s reload

Revoke by removing the entries and reloading. The reload is graceful and immediate.

External auth service via auth_request

For dynamic auth (OAuth, LDAP, token validation):

auth_request /auth;

location = /auth {
    internal;
    proxy_pass http://auth-service/validate;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Method $request_method;
}

The service answers 200 (allow) or 401/403 (deny). One subrequest per request, so slower than map auth at high concurrency.

Security considerations

No upload size limit

client_max_body_size 0 by default. Protect with ZFS quotas (zfs set quota=500G), filesystem quotas, or set client_max_body_size in nginx.

No rate limiting by default

Add limit_req for public deployments. See Nginx config.

Resource caps

Two app-level caps against authenticated DoS:

  • PUTFS_MAX_PATH_DEPTH (default 32): a PUT with more path segments is 400. Bounds the inodes a deep key creates (one file at depth N makes N directories).
  • PUTFS_MAX_LIST_KEYS (default 0, disabled): a listing past that many keys closes the connection mid-stream, never truncating silently. Enable on shared deployments; clients narrow with prefix or depth.

Key reload

Maps are loaded at startup; changes need nginx -s reload.

Further reading

Map-based API key auth is the canonical nginx open source pattern, not a PutFS invention: