Skip to main content

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​

BudgetLimitKey
Sign-in (/api/auth/*)60 per hour per IPip:auth:<ip>
Upload, IP60 per hour per IPip:upload:<ip>
Upload, wallet30 per hour per walletupload:<address>
Document lookup and removal, IP60 per hour per IPip:documents:<ip>
Document lookup, wallet120 per hour per walletdocuments:<address>
Document removal, wallet20 per hour per walletdocuments-remove:<address>
Status lists600 per hour per IPip:status-lists:<ip>
API reads, anonymous / standard key600 / 6,000 per hourip:api-read:<ip> / key:api-read:<id>
API verify, anonymous / standard key60 / 600 per hourip:api-verify:<ip> / key:api-verify:<id>
Monitor, report6 per 60 secondsops: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:

StoreUsed whenNotes
Upstash Redis (UPSTASH_REDIS_REST_URL and _TOKEN, or KV_REST_API_URL and _TOKEN)Whenever configured; required in productionA 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.
MemoryDevelopment and testsAt 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=Strict and Path=/api, valid 30 minutes.
  • The issuer role is re-read from the chain when it is more than 5 minutes old.
  • SESSION_SECRET must be at least 32 characters.

Details: Sign-in and sessions.

Input limits​

InputLimit
Sign-in bodyMessage 2,048 characters, signature 200
Upload4 MiB file (declared length checked first, plus 64 KiB form overhead), exactly one file
v0.3 upload contentMust parse as an encrypted container naming the session's wallet; plain PDF, PNG or JPEG refused
v0.2 upload contentDeclared type and magic bytes must both be PDF, PNG or JPEG
API file body65,536 characters, application/json only, checked against the declared length and while streaming
Hashes, ids, nonces, CIDs, addressesParsed with strict patterns before use
Status list pathFixed segments; purpose revocation or suspension; list number at most 999,999
Report monthThis 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 using timingSafeEqual, 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 signers check 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:

HeaderValue
Content-Security-Policyframe-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'
X-Frame-OptionsDENY
X-Content-Type-Optionsnosniff
Referrer-Policystrict-origin-when-cross-origin

Some routes tighten this:

PathExtra headersWhy
/openReferrer-Policy: no-referrer, Cache-Control: no-storeThe page holds a document key from a capability link in memory; no referrer leaves it and it is never cached
/wallet, /presentReferrer-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.jsCache-Control: no-cache, JavaScript content typeA fixed offline worker reaches phones on the next visit
No script-src in the policy

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​

RoutesPolicy
/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.ts
  • src/lib/server/sign-in.ts, session.ts, issuer-session.ts, role-check.ts
  • src/lib/server/api/auth.ts, api-limits.ts, body.ts, responses.ts
  • src/lib/server/ops/handler.ts, alerts.ts
  • src/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.ts
  • src/lib/server/api/auth.test.ts, api-limits.test.ts, handler.test.ts
  • src/lib/server/ops/handler.test.ts, alerts.test.ts
  • src/lib/security-headers.test.ts