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 O(1) hash – pure in-process 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 must match exactly
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):/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;
}

In the server block:

if ($deny) { return 403; }

Method patterns

Pattern Meaning
[^:]+ Any method (wildcard)
(GET|HEAD) Read only
(PUT|DELETE) Write only
GET GET only

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
    "~^PUTFS_acme_ro:(GET|HEAD):/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"
# Add to keys.conf, 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 / glob / 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