Server security controls
This page collects the server's protections in one place. Each one is described in more detail on the page of the route that uses it. The wider picture (what an attacker could try, and what the system does not protect against) is in Threat model and Known limits.
Design rules
- The chain decides. The server never stores credential state. Issuer role, status and anchors are read from the chain on each request (or from a short cache that is stated in signed answers).
- Least authority. No server key holds funds or can send a transaction that changes credential state. The API and status list keys only sign answers; the API's chain gateway refuses to sign transactions.
- Off is explicit. A feature with missing settings answers
CONFIG_MISSING; a malformed setting is an error that is logged and, for the monitor, alerted. - Fail closed. If the chain or the counter store cannot be read, the server refuses (503) instead of guessing "not an issuer", "not found" or "uncounted".
Rate limits
| Budget | Limit | Key |
|---|---|---|
Sign-in (/api/auth/*) | 60 per hour per IP | ip:auth:<ip> |
| Upload, IP | 60 per hour per IP | ip:upload:<ip> |
| Upload, wallet | 30 per hour per wallet | upload:<address> |
| Document lookup and removal, IP | 60 per hour per IP | ip:documents:<ip> |
| Document lookup, wallet | 120 per hour per wallet | documents:<address> |
| Document removal, wallet | 20 per hour per wallet | documents-remove:<address> |
| Status lists | 600 per hour per IP | ip:status-lists:<ip> |
| API reads, anonymous / standard key | 600 / 6,000 per hour | ip:api-read:<ip> / key:api-read:<id> |
| API verify, anonymous / standard key | 60 / 600 per hour | ip:api-verify:<ip> / key:api-verify:<id> |
| Monitor, report | 6 per 60 seconds | ops:check, ops:report |
Each scope has its own budget, so one round trip (sign in, then upload) does not use up another's quota. The API counts IPv6 addresses per /64 block, so one host cannot get fresh budgets by rotating addresses inside its block.
The store
src/lib/server/rate-limit.ts provides one interface (consume, putOnce, takeOnce) over two stores:
| Store | Used when | Notes |
|---|---|---|
Upstash Redis (UPSTASH_REDIS_REST_URL and _TOKEN, or KV_REST_API_URL and _TOKEN) | Whenever configured; required in production | A Lua script increments and sets the expiry in one atomic step, so parallel requests cannot slip past the limit. Nonces use SET ... EX and are taken with a delete that must return 1. |
| Memory | Development and tests | At most 10,000 keys, oldest evicted. Per server instance, so not a real limit on a platform with many instances. |
In production without Redis, getStore() returns nothing and sign-in, upload and documents answer CONFIG_MISSING. The API does the same unless API_ALLOW_LOCAL_LIMITS=true. Status lists fall back to memory counters because the lists are public and cached.
Client IP trust
clientIp takes the first address in X-Forwarded-For, then X-Real-IP, then unknown. That is safe only behind a proxy that sets these headers itself, as Vercel does (ADR-0031). A deployment behind another proxy must make sure the proxy overwrites them, or a client could pick its own IP and its own budget.
Sessions and sign-in
- SIWE messages are checked for chain, allowed domain (
SIWE_ALLOWED_DOMAINS, required in production), exact statement, expiry and a single-use nonce that lives 5 minutes. - Sessions are HS256 tokens with issuer and audience claims, in a cookie with
HttpOnly,Secure,SameSite=StrictandPath=/api, valid 30 minutes. - The issuer role is re-read from the chain when it is more than 5 minutes old.
SESSION_SECRETmust be at least 32 characters.
Details: Sign-in and sessions.
Input limits
| Input | Limit |
|---|---|
| Sign-in body | Message 2,048 characters, signature 200 |
| Upload | 4 MiB file (declared length checked first, plus 64 KiB form overhead), exactly one file |
| v0.3 upload content | Must parse as an encrypted container naming the session's wallet; plain PDF, PNG or JPEG refused |
| v0.2 upload content | Declared type and magic bytes must both be PDF, PNG or JPEG |
| API file body | 65,536 characters, application/json only, checked against the declared length and while streaming |
| Hashes, ids, nonces, CIDs, addresses | Parsed with strict patterns before use |
| Status list path | Fixed segments; purpose revocation or suspension; list number at most 999,999 |
| Report month | This month or one of the twelve before |
Secrets and comparisons
- API keys are stored only as SHA-256 hashes in
API_KEYS. The presented key is hashed and compared with every entry usingtimingSafeEqual, without stopping early. - The scheduler secret is compared in constant time, before any other work.
- The API and status list signer keys must match their published addresses before either feature serves anything, and the monitor's
signerscheck repeats that comparison. - Webhook addresses must be
https; alert posts refuse redirects.
Response headers
src/lib/security-headers.ts, applied in next.config.ts to every route:
| Header | Value |
|---|---|
Content-Security-Policy | frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self' |
X-Frame-Options | DENY |
X-Content-Type-Options | nosniff |
Referrer-Policy | strict-origin-when-cross-origin |
Some routes tighten this:
| Path | Extra headers | Why |
|---|---|---|
/open | Referrer-Policy: no-referrer, Cache-Control: no-store | The page holds a document key from a capability link in memory; no referrer leaves it and it is never cached |
/wallet, /present | Referrer-Policy: no-referrer, Permissions-Policy: camera=(self) | They read a presentation code from the address, and QR scanning needs the camera, for this site only |
/sw.js | Cache-Control: no-cache, JavaScript content type | A fixed offline worker reaches phones on the next visit |
The policy has no script-src directive. Next.js App Router pages use inline scripts, and wallet extensions inject scripts, both of which need nonces the app does not issue yet. The other directives still block framing, plugins, base-tag tricks and form posts to other sites.
CORS
| Routes | Policy |
|---|---|
/api/v1/* | Access-Control-Allow-Origin: *, methods GET, POST, OPTIONS, headers Authorization, Content-Type. Safe because answers are public, signed, and use no cookies. |
/api/status-lists/* | Access-Control-Allow-Origin: *, GET and OPTIONS |
/api/auth/*, /api/upload, /api/documents, /api/ops/* | No CORS headers, so browsers block cross-site reads. The session cookie is SameSite=Strict as well. |
Logging
- Pinata errors: HTTP status and request id.
- Role check errors: a summary with URLs replaced by
[redacted-url]. - API, status list and monitor errors: error names and codes, not messages.
- Never logged: uploaded files, API bodies, keys, secrets, session tokens, RPC or webhook addresses.
Monitor results and alerts name contract labels and counts only.
Source files
src/lib/server/rate-limit.ts,limits.ts,http.ts,config.tssrc/lib/server/sign-in.ts,session.ts,issuer-session.ts,role-check.tssrc/lib/server/api/auth.ts,api-limits.ts,body.ts,responses.tssrc/lib/server/ops/handler.ts,alerts.tssrc/lib/security-headers.ts,next.config.ts
Tests
src/lib/server/rate-limit.test.ts,limits.test.ts,http.test.ts,config.test.ts,role-check.test.tssrc/lib/server/api/auth.test.ts,api-limits.test.ts,handler.test.tssrc/lib/server/ops/handler.test.ts,alerts.test.tssrc/lib/security-headers.test.ts