Authentication
Handled by nginx – zero-subrequest map-based auth, or auth_request to an external service.
Map-based auth (nginx)
Two headers per request:
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:
~ 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:
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:
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(default32) – PUT to a key with more path segments returns400. Bounds the inode amplification frommkdir -p(one file at depth N creates N empty parent dirs).PUTFS_MAX_LIST_KEYS(default0= disabled) – when set, listings raiseListingTooLargemid-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 withprefix/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:
- Deploying NGINX as an API Gateway, Part 1 – official F5/NGINX guide;
map $http_apikey $api_client_nameis the key store. Its plain string keys match case-insensitively – harmless with hex credentials, which is why PutFS recommends them (see "Key case-sensitivity" above). - Community writeups of the same pattern: Mia-Platform – nginx for managing your API access, Protect REST API with nginx-based API keys, API key on Kubernetes nginx ingress, API key auth with nginx open source
- nginx
mapmodule – hash table lookups from request variables - nginx
auth_requestmodule – subrequest-based auth delegation