Skip to content

Security And Rate Limiting

Audience: Operators and developers responsible for production posture.

Scope: Where security headers come from, the rate-limit driver, the canonical policy table, and the env vars they depend on. Authoritative rules live in root .cursor/rules/120-security.mdc and root .cursor/rules/050-apis.mdc.

Security Headers

All security headers are applied globally by next.config.ts via @open-learning-hub/platform-config/security-headers (getSecurityHeaders() on every route). Do not duplicate them in route handlers, RSCs, or src/proxy.ts (proxy only sets tenant-resolution headers).

Implemented header set:

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: SAMEORIGIN
  • Referrer-Policy: strict-origin-when-cross-origin
  • X-XSS-Protection: 0 (browser XSS auditor disabled in favor of CSP)
  • Permissions-Policy: camera=(), microphone=(), geolocation=()
  • Reporting-Endpoints: csp-endpoint="/api/csp-report" (plus report-to csp-endpoint in CSP directives) so report-only violations can be delivered server-side via the Reporting API.
  • Content-Security-Policy-Report-Onlydefault-src 'self' with unsafe-inline / unsafe-eval for Next.js, Tailwind v4, and next-themes; img-src 'self' data: blob:; script-src / connect-src stay same-origin by routing browser PostHog traffic through /ingest/* rewrites. User avatars are fetched through same-origin GET /api/avatar so browser image requests do not need remote allowlist hosts.
  • Strict-Transport-Security — only when NODE_ENV === "production" (max-age=63072000; includeSubDomains; preload)

Unit tests: packages/platform-config/src/security-headers.test.ts.

Rate Limiting

Rate limits are applied at:

  • API route handlers under src/app/api/.
  • Server actions invoking RATE_LIMIT_POLICIES.<name>.

Driver

Selected via the RATE_LIMIT_DRIVER environment variable:

Driver When to use Notes
memory Local dev, unit tests, e2e In-memory fixed window; not durable; not safe across multiple processes; do not ship to prod.
upstash Production Backed by Upstash Redis; requires UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.

Policy Table

Endpoint / Action Limit Window Key scope
POST /api/auth/callback/credentials 10 requests 15 min IP + normalised email
POST /learn/sign-up 5 requests 1 hour IP + normalised email
POST .../enroll 10 requests 1 hour authenticated user
POST .../submit-quiz 30 requests 1 hour authenticated user
POST /api/cms/revalidate 60 requests 1 min IP
POST /api/csp-report 60 requests 1 min IP
GET /api/avatar 60 requests 1 min authenticated user

These limits are enforced by LMS wiring in src/lib/rate-limit/, backed by shared drivers from packages/server/src/rate-limit/. Exhaustion returns 429 Too Many Requests with a user-safe error body and a Retry-After header when known.

Identifiers in the key scope are hashed/normalised before composition. The action name is part of the key so different endpoints do not interfere with each other.

Auth Specifics

  • Auth.js cookies are HttpOnly, Secure (in production), and SameSite=Lax.
  • CSRF: relied on Auth.js cookie protection for sign-in endpoints; double-submit token for state-changing API routes outside the Auth.js flow.
  • users.token_version is bumped on role change, deactivation, force-sign-out-everywhere, and tenant suspension. JWTs whose claim no longer matches are rejected by proxy.ts.

Email Tokens

verification_tokens are hashed at rest. used_at enforces single-use. TTLs are governed by TOKEN_TTL_SECONDS and listed in development/auth.md.

Sign-up, password-reset, and magic-link flows are rate-limited; responses MUST NOT leak user existence (no different status code for "no such user" vs "valid user").

CMS Revalidation Webhook

POST /api/cms/revalidate requires the shared secret CMS_WEBHOOK_SECRET in Authorization: Bearer <secret> or x-cms-webhook-secret. Body validation, allowed tag prefixes, and rate-limit behaviour live in root .cursor/rules/050-apis.mdc.

Required Environment Variables

Variable Purpose
AUTH_SECRET Auth.js JWT signing key (min 32 random bytes).
AUTH_URL Public origin (e.g. https://example.com).
AUTH_TRUST_HOST true when running behind a proxy.
AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET Optional. Google OAuth provider credentials.
DATABASE_URL Postgres connection string in production.
CMS_API_URL, CMS_PROJECT_UUID, CMS_API_UUID Headless CMS contract.
CMS_WEBHOOK_SECRET Inbound webhook auth.
NEXT_PUBLIC_PAGE_SIZE Optional shared UI page size override (defaults to 20; clamped to 1-100).
RATE_LIMIT_DRIVER memory for dev/test, upstash for prod.
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN Required in production with the upstash driver.
EMAIL_FROM Verified sender address.
RESEND_API_KEY Primary email provider credential.
SMTP_* Nodemailer fallback credentials (optional).
APP_BASE_DOMAIN Base host for tenant subdomains (e.g. lvh.me:3001 locally).
POSTHOG_PROJECT_KEY, POSTHOG_HOST Server PostHog analytics/observability (optional; host defaults to US Cloud).
NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST Browser PostHog analytics (optional; host defaults to US Cloud).

Server-side variables MUST NOT use the NEXT_PUBLIC_ prefix. POSTHOG_PROJECT_KEY and POSTHOG_HOST are server-only; only the NEXT_PUBLIC_POSTHOG_* values are exposed to the browser bundle.

API error contract

Route handlers and server actions share { error, code, issues? } (HTTP) and ActionResult<T> (actions). See API and action errors.