Skip to content

Authentication

Handled by nginx – zero-subrequest map-based auth, 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, both resolved in-process 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 checks plus the /_/dl/ presign carve-out into a single deny gate – every failure mode returns the same 403. There is no 401; a distinct status for "wrong key" vs "wrong scope" would let an attacker probe for valid keys by watching status codes.

$uri is nginx's normalised, percent-decoded path (no query string, .. already collapsed). Matching auth on $uri rather than the raw $request_uri means the auth check, the try_files lookup, and the upstream all see the same canonical path – encoding tricks like %2F cannot diverge auth from the served file.

Keys file

Both maps live in a single file included by nginx:

# keys.conf
# Regenerate on key changes, nginx -s reload to apply

# Authentication: key + secret – O(1) hash lookup on a plain string key.
# nginx matches string keys case-insensitively; use hex secrets, which
# carry no entropy in case (see "Key case-sensitivity" 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 = block (return 403), 0 = allow.
# /_/dl/ bypasses (HMAC validates inside the presign location).
# Otherwise both $key_ok and $auth_ok must be 1.
map "$uri:$key_ok:$auth_ok" $deny {
    default          1;
    "~^/_/dl/"       0;
    "~:1:1$"         0;
}

Key case-sensitivity

nginx map matches plain string keys case-insensitively: a "PUTFS_x:secret" entry also accepts SECRET, Secret, and every other case variant. With mixed-case secrets that silently folds away the case bits of the secret's entropy – so prefer hex keys and secrets (openssl rand -hex 32, as in Key management below): hex carries no entropy in case, case-insensitive matching costs nothing, and the lookup stays a single O(1) hash probe.

If you want or need case-sensitive matching anyway (e.g. imported base64 secrets whose entropy does live partly in case), wrap each $key_ok entry as a regex key instead:

map "$http_x_api_key:$http_x_api_secret" $key_ok {
    default                          0;
    "~^\QPUTFS_abc123:sEcR3t+X/Y\E$"  1;
}

~ makes the key a case-sensitive regex, \Q...\E quotes any regex metacharacters in the secret verbatim, ^...$ anchors the exact match. The trade-off: nginx tries regex keys sequentially per request instead of one hash lookup – irrelevant for a handful of keys, measurable for hundreds. $auth_ok uses regex keys either way, so method and path scoping is always case-sensitive.

In the server block:

if ($deny) { return 403; }

Method patterns

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

QUERY must be granted explicitly

Listing moved from GET /prefix/ to QUERY /prefix/. A scope written as (GET|HEAD) still downloads objects but can no longer list them – nginx returns the same 403 as any other scope failure, so the symptom is a listing that stops working with no other signal. Wildcard ([^:]+) scopes are unaffected.

Scope examples

map "$http_x_api_key:$request_method:$uri" $auth_ok {
    # deny any other access
    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

Issue a key:

KEY_ID="PUTFS_$(openssl rand -hex 8 | tr '[:lower:]' '[:upper:]')"
SECRET="$(openssl rand -hex 32)"
echo "Key ID: $KEY_ID"
echo "Secret: $SECRET"
# Hex only – no entropy in case, safe for the case-insensitive string map.
# Add to keys.conf:
#   "${KEY_ID}:${SECRET}"   1;
# plus a matching $auth_ok scope line. Then:
nginx -t && nginx -s reload

Revoke a key – remove the entries from keys.conf, then:

nginx -t && nginx -s reload

Revocation is immediate after reload. Reload is graceful – no connections dropped.

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;
}

Returns 200 (allow) or 401/403 (deny). Fires a subrequest per connection – slower than map auth at high concurrency.

Security considerations

No upload size limit

Default client_max_body_size 0 – no limit. 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 mitigate authenticated DoS:

  • PUTFS_MAX_PATH_DEPTH (default 32) – PUT to a key with more path segments returns 400. Bounds the inode amplification from mkdir -p (one file at depth N creates N empty parent dirs).
  • PUTFS_MAX_LIST_KEYS (default 0 = disabled) – when set, listings raise ListingTooLarge mid-stream after yielding that many keys, closing the connection abruptly. Never silent truncation – callers that rely on exact iteration are not corrupted, they fail. Enable on shared/public deployments where you'd rather break large listings than tie up workers indefinitely; clients can then narrow with prefix / depth.

Raise / enable as your trust model requires; defaults preserve correctness.

Key reload

Map-based keys are loaded at startup. Changes require nginx -s reload.

Further reading

Map-based API key auth is the canonical pattern for nginx open source, not a PutFS invention – the official NGINX API gateway guide stores keys in the same kind of map block (with auth_request only as plumbing to turn the map result into a status code) and recommends generating the keys file from a credential store: