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:
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:
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(default32): a PUT with more path segments is400. Bounds the inodes a deep key creates (one file at depth N makes N directories).PUTFS_MAX_LIST_KEYS(default0, 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:
- Deploying NGINX as an API Gateway, Part 1: the official guide keeps keys in the same kind of
map - Community writeups: Mia-Platform, Protect REST API with nginx-based API keys, API key on Kubernetes nginx ingress, API key auth with nginx open source
- nginx
mapmodule - nginx
auth_requestmodule