API Security & Token Hardening
Enterprise-grade API security architecture: 4-layer defense-in-depth, bearer tokens, tenant isolation, rate limiting, CSRF, and CSP.
On this page
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.
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, requiringsecure: true,path: "/", and forbiddingdomaindelegation. This guarantees subdomains cannot plant, spoof, or overwrite cookies.- Cookie Deletion Parity:
clearSessionCookiessets 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 andsha256(query)is compared withtimingSafeEqualbefore the query is registered — a client cannot plant a query under a hash another client will request (PERSISTED_QUERY_HASH_INVALIDfor a malformed key,PERSISTED_QUERY_HASH_MISMATCHfor 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.
websocketOriginAllowedinsrc/hooks.ws.tsrejects anOriginwhose 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
scimnamespace is absent fromENDPOINT_PERMISSIONSand_checkEndpointPermissionreturnsfalsefor it directly after the admin fast-path, so non-admin roles get403regardless 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.tsrejects 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) andPOST /api/media/processwithprocessType=batch(MAX_BATCH_PROCESS_IDS) enforce an upper-bound cap (ids.length <= 100). Exceeding this limit returns an immediate400 INVALID_BATCH_SIZEbefore any per-item work starts — the media batch handler parses and validatesmediaIdsfirst, soMediaService.batchProcessImagesnever receives an oversized list (malformed JSON or a non-array payload returns400 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 inscripts/scan-security-risk.tsscans 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. InNODE_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
- Token Generation: 256-bit CSPRNG token (
crypto.getRandomValues()) placed in thecsrf_token/__Host-csrf_tokencookie (sameSite: "strict",httpOnly: false,path: "/"). - Double Submission: Browser clients extract the cookie value and submit it in the
X-CSRF-TokenHTTP header on every mutating API request (clientJsonHeaders()). - Constant-Time Comparison:
validateCsrfTokenvalidates 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; - 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.
- Same-Origin Fast-Path: Same-origin requests (
originUrl.host === hostorrefererUrl.host === host) pass verification via nanosecond string comparison. - 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): WhenmfaRequired: 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) andmfaVerifiedAtare realauth_sessionscolumns on all four adapters — declared in the system schema spec, materialised by the Drizzle schemas (SQLite/PostgreSQL/MariaDB) and Mongo’sSessionSchema. 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"]oramr: ["pwd", "trusted_device"]) are immediately rejected with HTTP 403MFA_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 withmfaRequired: true. Interactive second factor is required on every login. - Rotation preserves the proof: token rotation copies
amrandmfaVerifiedAtonto 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:
- 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. - 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.
- 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.
- 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) inhandle-authentication.tsavoiding circular dependencies and Promise overhead.
- 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-minuteos.loadavg() / coreswhen 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.
- Polls every 5s: process
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 insrc/databases/core/relational-utils.tsdurchassertSafeSqlIdentifiergegen 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 (PostgreSQLNAMEDATALEN), um Kollisionen durch stumme Identifier-Kürzung auszuschließen.
10.2 Mandanten-Isolation & Ghost-Column Schutz
- Fail-Closed Tenant Guard (
safeQuery): Insrc/utils/security/safe-query.ts: ImMULTI_TENANT-Modus wird jede Datenbankabfrage abgefangen. Fehlt dertenantId-Kontext oder versucht ein Client, Daten eines fremden Mandanten abzufragen, blockiertsafeQuerybzw.assertTenantContextdie Abfrage mitTENANT_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 (getPhysicalSelectioninsrc/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 mitSharedArrayBuffer/AtomicsundBroadcastChannel("sveltycms:roles")invalidiert Rollen-Caches im Speicher atomar über alle Worker-Threads hinweg, sobald ein Administrator Rechte modifiziert.