Skip to content

Documentation

API Security & Token Hardening

Enterprise-grade API security architecture: 4-layer defense-in-depth, bearer tokens, tenant isolation, rate limiting, CSRF, and CSP.

10/11/2026
17 min read Edit on GitHub

SveltyCMS employs a zero-trust architecture for API access — authentication alone is insufficient. Every API request passes through 4 independent security layers that re-validate permissions at each boundary.


1. Defense-in-Depth Architecture

Request → Layer 1 (Middleware) → Layer 2 (Dispatcher) → Layer 3 (Handler) → Layer 4 (Page Action) → Response
Layer What It Checks Failure Mode
Layer 1 — Middleware CSP, CORS, HSTS, X-Frame-Options, firewall patterns, body size (15 MB), rate limiting, __Host- cookie prefix, CSRF 403/429 before any business logic runs
Layer 2 — Dispatcher ENDPOINT_PERMISSIONS mapping, namespace-root verification (no action segment ⇒ not public), SCIM blocking, admin fast-path 403 for any unmapped namespace
Layer 3 — Handler Admin verification for mutations, media:write/media:delete checks 403 on unauthorized mutations
Layer 4 — Page Action requireCollectionBuilderPermission(), inline RBAC checks 403 before state change

Fail-Closed Default: If a permission mapping is missing or ambiguous, the dispatcher returns 403 — never 200 with leaked data.

Important

Namespace roots are not public (September 2026). For user and auth, the dispatcher no longer treats a route with no action segment as an exemption: GET /api/user (directory listing) requires user:read and POST /api/user (account creation) requires user:write. Only named self-service actions (me, login, logout, OIDC/SAML callbacks, 2fa*) and bare GET /api/auth (the caller’s own session user) skip the permission lookup; non-admin create payloads are additionally stripped of role/roles/isAdmin/roleIds/permissions via sanitizeClientUserAttributePatch. tests/unit/api/authz-bypass-guard.test.ts enumerates every namespace root and public-route exemption against a reasoned contract so a new bypass fails a unit test.


2. Token Architecture

2.1 Website Tokens (API Keys)

Designed for machine-to-machine communication. Stateless secrets bound to a specific tenantId and role.

Property Detail
Generation CSPRNG with rejection sampling (zero modulo bias)
Storage SHA-256 hashed before persistence — plaintext never stored
Types content-api (read/restricted), admin-api (full access)
Expiry Configurable per-token with automatic invalidation
Consumption Atomic one-time token support via dbAdapter.system.websiteTokens.consume() — prevents TOCTOU race conditions
Lookup Single-pass getByToken — no redundant round-trips

2.2 Session & CSRF Cookies

Browser-based authentication and mutation security rely on hardened cookie structures:

Cookie Name (Prod / Dev) Attributes Purpose
Session Cookie __Host-auth_sessions / auth_sessions httpOnly: true, secure: true, sameSite: "strict", path: "/" Holds the 32-character CSPRNG session token. Inaccessible to JavaScript (XSS mitigation).
CSRF Cookie __Host-csrf_token / csrf_token httpOnly: false, secure: true, sameSite: "strict", path: "/" 256-bit CSPRNG token read by client fetch utilities to attach the X-CSRF-Token header.
  • __Host- Prefix Contract (RFC 6265bis): In HTTPS environments, cookies use the __Host- prefix, requiring secure: true, path: "/", and forbidding domain delegation. This guarantees subdomains cannot plant, spoof, or overwrite cookies.
  • Cookie Deletion Parity: clearSessionCookies sets identical cookie attributes (path: "/", sameSite: "strict", secure: true, httpOnly: true) upon logout or invalidation across all prefix variants (__Host-, __Secure-, plain), ensuring complete cookie removal across all browsers.
  • Session Lifecycle & Invalidation:
    • Auto-rotated upon login and session renewal.
    • Password change or logout triggers cascading invalidation across all devices.
    • 3-layer caching: Memory (L1) → Redis (L2) → Database (L3) with credential-free memory caching.

2.3 Token Namespace Authorization

/api/token/* is a positive allowlist, not a public-route deny-list:

Route pattern Access
GET /api/token/validate-token/{value} Public — invitation / verification links.
GET /api/token/{value} (legacy alias) No longer public — unauthenticated requests return 401; signed-in callers still get the validation action, but must pass the dispatcher system:read gate and the handler’s admin / api:token check.
GET /api/token/list Admin session or the mapped api:token permission.
POST /api/token/create-token, resolve, batch Admin session or api:token.
PUT / DELETE /api/token/{id} Admin session or api:token. Updates are field-allowlisted — see below.

PUT /api/token/{id} accepts only expires, blocked and consumed from a caller holding api:token; admins may additionally set email and role. Identity and flow fields — user_id, type, token, _id and tenantId — are never client-writable, closing the account-takeover vector of re-pointing a reset token at another user. Website tokens (/api/website-tokens) stay strictly admin-only. Coverage: tests/unit/api/token.test.ts, tests/integration/api/token.test.ts, tests/unit/api/authz-bypass-guard.test.ts.


3. Authentication Flow

3.1 Bearer Token Validation

1. Extract token from Authorization: Bearer <token> header
2. Single DB query: dbAdapter.system.websiteTokens.getByToken
3. Normalize legacy tokens → default to content-api
4. Create Virtual User context (no full User document hydration)
5. Verify tenantId match → reject mismatched tokens
locals.user = {
  _id: `token:${token._id}`,
  role: token.type === "admin-api" ? "admin" : "guest",
  tenantId: token.tenantId ?? locals.tenantId,
  isApiToken: true,
};

3.2 GraphQL Authentication

GraphQL requests pass through the same middleware pipeline as REST:

  • HTTP entry: handleRequest() rejects unauthenticated requests (401) before schema execution
  • Validation plugin: Query depth limit (8), alias limit (15)
  • APQ hash binding: for extensions.persistedQuery, the hash must be exactly 64 hex characters and sha256(query) is compared with timingSafeEqual before the query is registered — a client cannot plant a query under a hash another client will request (PERSISTED_QUERY_HASH_INVALID for a malformed key, PERSISTED_QUERY_HASH_MISMATCH for a wrong digest), which closes cache poisoning/flooding of the persisted-query store
  • Introspection: Explicitly blocked in production via NoSchemaIntrospectionCustomRule
  • Subscriptions: Context handler validates sessions before WebSocket upgrade. websocketOriginAllowed in src/hooks.ws.ts rejects an Origin whose host differs from the upgrade host. An empty Origin is allowed (non-browser clients). A malformed Origin is rejected. The host check runs before the test-secret path.

3.3 SCIM 2.0 Authentication

SCIM endpoints (/api/scim/v2/Users, /Groups, /Bulk) use Bearer token authentication with additional tenant-scoped RBAC checks:

  • SCIM is admin-session only: the scim namespace is absent from ENDPOINT_PERMISSIONS and _checkEndpointPermission returns false for it directly after the admin fast-path, so non-admin roles get 403 regardless of grants (src/routes/api/[...path]/+server.ts)
  • Bulk operations are validated for cross-tenant leakage before execution
  • PATCH operations use RFC 7644-compliant filter evaluation

4. Multi-Tenant Hardening

4.1 Orphaned Token Protection

If a token’s owner is deleted (tenantId: null), the system isolates the token to the current request’s tenant context — preventing cross-tenant privilege escalation.

4.2 Tenant Isolation Checks

Every Bearer-token request is verified against the environment’s tenantId. Mismatched tokens are rejected with 403 and an audit log event.

4.3 Batch Operation Guard & Upper-Bound Limits

To prevent memory exhaustion and DoS attacks through oversized payloads:

  • Mixed-Tenant Rejection: batch-module.ts rejects coalesced operations containing mixed tenant IDs — preventing cross-tenant data leakage in shared-DB deployments.
  • Batch Size Ceiling: The batch endpoints POST /api/token/batch (MAX_BATCH_SIZE), POST /api/user/batch (MAX_USER_BATCH_SIZE), GET /api/media/bulk-download (MAX_BULK_DOWNLOAD_IDS) and POST /api/media/process with processType=batch (MAX_BATCH_PROCESS_IDS) enforce an upper-bound cap (ids.length <= 100). Exceeding this limit returns an immediate 400 INVALID_BATCH_SIZE before any per-item work starts — the media batch handler parses and validates mediaIds first, so MediaService.batchProcessImages never receives an oversized list (malformed JSON or a non-array payload returns 400 INVALID_BATCH_IDS).
  • Single-Query Batch Operations: Batch token deletion uses single round-trip database queries (DELETE ... WHERE id IN (...) / deleteMany), eliminating N+1 sequential database round-trips.
  • AST Scanner Guard (batch-size-unbounded): Static security analysis in scripts/scan-security-risk.ts scans all API handlers using the TypeScript compiler AST to ensure every batch block enforces a length cap assertion before processing.

5. Rate Limiting & DoS Prevention

Mechanism Location Detail
Login brute-force handle-authentication.ts handle-rate-limit.ts (RateLimiter) — 5 attempts → 15-min lockout (HTTP 423)
API rate limit handle-rate-limit.ts Hardware-aware Token-Bucket (Redis L2 primary + local memory fallback); commerce lane isolated (X-RateLimit-Lane)
WAF endpoint limits SecurityResponseService rate-limiter-flexible per path; /api/commerce is 60/min (prefix-matched)
Body size limit api-handler.ts / dispatcher 15 MB ceiling (API_MAX_BODY_SIZE_BYTES in @utils/api-body-limits) — returns 413 PAYLOAD_TOO_LARGE before parsing; chunked bodies (Transfer-Encoding: chunked, no Content-Length) are counted through a zero-copy stream and stopped at the same ceiling
Query page size @utils/api-params MAX_PAGE_SIZE = 200 clamp on REST parsers, the SDK find funnel and the GraphQL fast path + resolver — a client-supplied limit never reaches the adapter unbounded
Memory load shedding hooks.server.ts Rejects mutation traffic (503) when heap > 90%
Retry-After header RFC 6585 compliant All 429 responses include Retry-After: 60 and X-RateLimit-* headers

6. CSP & Security Headers

All API responses include mandatory security headers:

Header Value Purpose
Content-Security-Policy script-src 'self' (production API routes) Blocks inline script execution
Strict-Transport-Security max-age=31536000; includeSubDomains Enforces HTTPS
X-Frame-Options DENY Prevents clickjacking
X-Content-Type-Options nosniff Prevents MIME sniffing
Cross-Origin-Opener-Policy same-origin Spectre/Meltdown protection
Cross-Origin-Embedder-Policy require-corp Cross-origin isolation
Cross-Origin-Resource-Policy same-origin Resource isolation
Referrer-Policy strict-origin-when-cross-origin Referrer leakage control

GraphQL Playground CSP: The relaxed CSP (unsafe-inline, unsafe-eval) is only applied in non-production environments. In NODE_ENV=production, GraphQL endpoints use the same strict CSP as REST APIs.


7. CSRF Protection & Request Verification Architecture

SveltyCMS enforces a hardened Double-Submit Cookie Pattern combined with multi-layer origin and referer verification on all mutating requests (POST, PUT, PATCH, DELETE).

7.1 The Double-Submit Handshake

Client Request (Mutation)
  ├─ Cookie: __Host-csrf_token=<token_A> (or csrf_token in dev)
  ├─ Header: X-CSRF-Token: <token_A>
  └─ Header: Origin / Referer: https://example.com
       │
       ▼
[1] Safe-Method Bypass (GET / HEAD / OPTIONS) ───► 0µs cost, CSRF skipped
       │
[2] Token-Bound Bypass (Bearer / API Key / SCIM) ──► CSRF skipped (stateless)
       │
[3] Same-Origin Fast-Path (Origin/Referer === Host) ──► Validated in nanoseconds
       │
[4] Constant-Time Token Comparison ──────────────► XOR accumulator (no timing leak)
       │
[5] Single-Use Token Rotation ───────────────────► Invalidate old token, issue fresh cookie
  1. Token Generation: 256-bit CSPRNG token (crypto.getRandomValues()) placed in the csrf_token / __Host-csrf_token cookie (sameSite: "strict", httpOnly: false, path: "/").
  2. Double Submission: Browser clients extract the cookie value and submit it in the X-CSRF-Token HTTP header on every mutating API request (clientJsonHeaders()).
  3. Constant-Time Comparison: validateCsrfToken validates the header token against the cookie using a bitwise XOR accumulator:
    let result = 0;
    for (let i = 0; i < cookieToken.length; i++) {
      result |= cookieToken.charCodeAt(i) ^ tokenToValidate.charCodeAt(i);
    }
    return result === 0;
  4. Single-Use Rotation: On every successful mutation, the server immediately invalidates the consumed CSRF token and issues a fresh token in the response cookie, preventing replay attacks.
  5. Same-Origin Fast-Path: Same-origin requests (originUrl.host === host or refererUrl.host === host) pass verification via nanosecond string comparison.
  6. Stateless Exemption: Machine-to-machine requests utilizing Bearer tokens, API Keys, or SCIM credentials are automatically exempt from cookie CSRF (they do not rely on ambient browser cookies).

7.2 Check Parity: Read Paths vs. Mutation Paths

To ensure optimal throughput and prevent performance bottlenecks, SveltyCMS maintains strict check parity without duplicate or redundant operations:

Execution Stage Read Paths (findById, find, list) Mutation Paths (create, update, bulk)
CSRF Verification 0 checks (0µs overhead) — Safe HTTP verbs (GET/HEAD) bypass CSRF. 1 check at the API gateway layer (validateCsrfForRequest).
Authentication & RBAC Single check in handleAuthentication + handleAuthorization (cached in locals). Single check in middleware + endpoint permission mapping (_checkEndpointPermission).
Schema Resolution Single lookup from hot in-memory LRU (schemaOf). Single lookup from hot in-memory LRU (schemaOf).
Payload Preparation Skipped on read. Single unified pass (prepareWritePayload) combining sanitization, type validation, and write guards.
Database Execution Single direct prepared query (findById / findOne). Single atomic prepared statement (insert / update with RETURNING).
Post-Write Processing None. Detached / non-blocking background execution (schedulePostWrite) — does not delay response.

7.3 Role-Based Multi-Factor Authentication Enforcement (MFA-RBAC)

SveltyCMS enforces mandatory multi-factor authentication on a per-role basis:

  • Per-Role Mandate (Role.mfaRequired): When mfaRequired: true, users assigned to this role must complete interactive 2FA enrollment and verification before accessing protected system resources.
  • Session AMR Tracking (RFC 8176): Sessions capture Authentication Methods References:
    • amr: ["pwd"] — Password authentication only.
    • amr: ["pwd", "mfa"] — Interactive 2FA verified (TOTP, hardware passkey/WebAuthn, backup code).
    • mfaVerifiedAt — ISO timestamp recording when second-factor elevation occurred.
  • Persisted on every engine: amr (JSON/JSONB) and mfaVerifiedAt are real auth_sessions columns on all four adapters — declared in the system schema spec, materialised by the Drizzle schemas (SQLite/PostgreSQL/MariaDB) and Mongo’s SessionSchema. An existing SQL table that predates the columns receives them on the next boot. Before the columns existed, the SQL adapters accepted and dropped both fields on write, so MFA elevation survived a reload only on Mongo.
  • Endpoint Gate Enforcement: In _checkEndpointPermission, requests made under a mandatory-MFA role that carry non-MFA sessions (amr: ["pwd"] or amr: ["pwd", "trusted_device"]) are immediately rejected with HTTP 403 MFA_REQUIRED.
  • Exempt Routes: Self-service 2FA setup/verification (/api/auth/2fa/*) and session teardown (/api/auth/logout) remain accessible so users can authenticate their factor or safely log out.
  • Trusted-Device Override: Device-level trust (__Host-2fa-trusted-device) is unconditionally disabled for any user assigned to a role with mfaRequired: true. Interactive second factor is required on every login.
  • Rotation preserves the proof: token rotation copies amr and mfaVerifiedAt onto the new session row, so a renewed cookie keeps its MFA claim — and a password-only session cannot acquire one by rotating.

7.4 Hardware-Aware Token-Bucket Rate Limiter & Throttling

To eliminate the 2× traffic spike artifacts inherent in fixed-window rate limiters, SveltyCMS implements a continuous Token-Bucket rate limiting engine in src/hooks/handle-rate-limit.ts:

flowchart TD REQ[Incoming HTTP Request] --> SYNC_AUTH[Sync Session Peeker] SYNC_AUTH --> TIER{User Privilege Tier} TIER -->|Admin| CAP_ADMIN[Capacity Multiplier: 10x] TIER -->|Authenticated| CAP_USER[Capacity Multiplier: 2x] TIER -->|Anonymous| CAP_ANON[Capacity Multiplier: 1x] CAP_ADMIN & CAP_USER & CAP_ANON --> HW{SystemMonitor CPU/Lag} HW --> COST[Adjusted Token Cost] COST --> REDIS_CHECK{Redis Active & Circuit Closed?} REDIS_CHECK -->|Yes| REDIS_LUA[Atomic Lua Token-Bucket Script <15ms] REDIS_CHECK -->|No / Timeout / Circuit Open| MEM_FALLBACK[Local In-Memory Token Bucket <2µs] REDIS_LUA & MEM_FALLBACK --> EVAL{Tokens Available?} EVAL -->|Yes| ALLOW[Allow Request + Append X-RateLimit Headers] EVAL -->|No| REJECT[429 Too Many Requests + Retry-After]
  1. Continuous Token Replenishment: Rather than resetting counters at minute boundaries, tokens are replenished continuously based on elapsed milliseconds (refillRatePerMs = capacity / windowMs). Requests consume tokens based on adjusted cost.
  2. Distributed Redis L2 Primary: Multi-node deployments execute an atomic Lua script directly on Redis:
    • Computes dynamic replenishment and token decrement in a single network round-trip.
    • Sets key TTL automatically matching the window duration.
  3. Resilient Circuit-Breaker Fallback:
    • Redis commands are constrained by a strict 15 ms timeout.
    • On Redis connection failure, timeout, or error, an internal circuit breaker trips with a 5-second backoff.
    • Requests immediately fall back to a high-speed, local in-memory Token Bucket (< 2 µs), ensuring zero downtime and zero failed requests during Redis outages.
  4. Adaptive User-Tier Quotas:
    • Administrators: $10\times$ capacity multiplier to guarantee unblocked CMS operations during batch management.
    • Authenticated Users: $2\times$ capacity multiplier for verified editors and interactive staff.
    • Anonymous / Public: $1\times$ baseline quota protecting against unauthenticated scrapers and brute-force attempts.
    • Evaluated via a zero-overhead synchronous session cache peeker (peekSessionFromCacheSync) in handle-authentication.ts avoiding circular dependencies and Promise overhead.
  5. Hardware-Aware Load Shedding (system-pressure.ts — the only rate-limit pressure source):
    • Polls every 5s: process cpuUsage() delta ÷ (elapsed µs × core count), plus 1-minute os.loadavg() / cores when the platform reports a non-zero load. Windows loadavg is zeros and is ignored.
    • EWMA (α=0.25) so a single spike does not throttle. Admin tier stays at 1.0. Heap > 92% rejects mutations with 503 HEAP_PRESSURE.
    • Predictive histogram (request-clock.ts) can raise pressure before CPU moves. Endpoint token costs (endpoint-cost.ts) debit more than 1 token for media/AI/import/GraphQL.
    • RBAC role tiers are seeded from getAllRoles() into an in-memory set; tenant plan (free/pro/enterprise) is seeded from TenantService. The limiter never queries the DB.
    • One hook: handle-rate-limit.ts (Redis L2 + memory). The previous opt-in Redis handle is gone.

8. Performance Benchmarks

All metrics self-measured via bun test tests/benchmarks/ — reproducible on any machine.

Metric p95 Adapter
Bearer token validation 0.06 ms SQLite (Local SDK)
Bearer token validation 0.37 ms MariaDB
Session validation (L1 cache hit) <0.001 ms Memory
Session validation (L3 DB fallback) 1.78 ms PostgreSQL
Full auth pipeline (middleware → RBAC) 1.61 ms All adapters
API request (auth + RBAC + response) 1.16 ms SQLite
Concurrent throughput 1,054 RPS MariaDB

Reproduce: bun test tests/benchmarks/auth-performance.test.ts


9. Audit & Compliance

  • Crypto-chained logs: Every API mutation is logged with SHA-256 chained audit entries — tamper-evident verification
  • security.txt: RFC 9116 standard at /.well-known/security.txt
  • EU Directive 2006/114/EC: All competitive comparisons use verifiable public data
  • Incident response: Full runbook
  • 0 published CVEs — private reporting via GitHub Security Advisories

10. Core Logic & Database Query Hardening

SveltyCMS embeds a multi-tier defense-in-depth model directly into its custom business and database access logic:

10.1 SQL Injection Protection (Parametrization & Identifier Whitelisting)

  • Prepared Parameter Binding: Weder in PostgreSQL (postgres.js), MariaDB (mysql2), noch SQLite (bun:sqlite/better-sqlite3) werden dynamische Werte konkateniert. Alle Filter (safeQuery, mapQuery, prepareValues) binden Werte ausschließlich als platzhalterbasierte Parameter (? bzw. $1, $2, ...).
  • Identifier Whitelisting (assertSafeSqlIdentifier): Dynamische Spaltennamen oder Tabellenbezeichner werden in src/databases/core/relational-utils.ts durch assertSafeSqlIdentifier gegen den regulären Ausdruck /^[A-Za-z_][A-Za-z0-9_]*$/ validiert. Jeder Bezeichner mit Semikolons, Quotes, Backticks oder Whitespace löst sofort eine Exception aus, bevor das SQL zusammengesetzt wird. Zudem erzwingt die Routine eine Maximallänge von 63 Zeichen (PostgreSQL NAMEDATALEN), um Kollisionen durch stumme Identifier-Kürzung auszuschließen.

10.2 Mandanten-Isolation & Ghost-Column Schutz

  • Fail-Closed Tenant Guard (safeQuery): In src/utils/security/safe-query.ts: Im MULTI_TENANT-Modus wird jede Datenbankabfrage abgefangen. Fehlt der tenantId-Kontext oder versucht ein Client, Daten eines fremden Mandanten abzufragen, blockiert safeQuery bzw. assertTenantContext die Abfrage mit TENANT_CONTEXT_MISSING (HTTP 500) und redigiert PII-Felder (email, secret, token, etc.) im Log.
  • Physical Column Selection (getPhysicalSelection): Auf Core-Tabellen (content_nodes, audit_logs, auth_users) werden Spalten über explizite Sets selektiert (getPhysicalSelection in src/databases/core/drizzle-sql-helpers.ts). Dies verhindert “Ghost Columns” und ungewollte Daten-Leaks in minifizierten Produktions-Chunks.

10.3 Authentication & Granular Access Control (RBAC)

  • Granular Endpoint Permissions (ENDPOINT_PERMISSIONS): Jeder /api-Endpunkt ist in einer zentralen Matrix typisiert und fail-closed abgesichert. Routen ohne explizites Permission-Mapping werden im Middleware-Hook mit HTTP 403 abgewiesen.
  • Constant-Time Operations (timingSafeStringEqual): Alle API-Keys, Session-Tokens, Webhook-Signaturen und Passwörter werden in $O(\max(\text{lenA}, \text{lenB}))$ bitweise verglichen, um CPU-Cache-Line-Timing-Angriffe auszuschließen.
  • 64-Bit Bitmask Security Engine (permission-bitmask.ts): Rollenberechtigungen werden in Single-Instruction CPU-Register-Prüfungen (((mask & req) !== 0n)) ausgewertet. Ein dynamischer Revisions-Zähler mit SharedArrayBuffer/Atomics und BroadcastChannel("sveltycms:roles") invalidiert Rollen-Caches im Speicher atomar über alle Worker-Threads hinweg, sobald ein Administrator Rechte modifiziert.

Related

securityauthapihardeningtokensrate-limiting
Was this page helpful?