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.mdcand 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: nosniffX-Frame-Options: SAMEORIGINReferrer-Policy: strict-origin-when-cross-originX-XSS-Protection: 0(browser XSS auditor disabled in favor of CSP)Permissions-Policy: camera=(), microphone=(), geolocation=()Reporting-Endpoints: csp-endpoint="/api/csp-report"(plusreport-to csp-endpointin CSP directives) so report-only violations can be delivered server-side via the Reporting API.Content-Security-Policy-Report-Only—default-src 'self'withunsafe-inline/unsafe-evalfor Next.js, Tailwind v4, and next-themes;img-src 'self' data: blob:;script-src/connect-srcstay same-origin by routing browser PostHog traffic through/ingest/*rewrites. User avatars are fetched through same-originGET /api/avatarso browser image requests do not need remote allowlist hosts.Strict-Transport-Security— only whenNODE_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), andSameSite=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_versionis bumped on role change, deactivation, force-sign-out-everywhere, and tenant suspension. JWTs whose claim no longer matches are rejected byproxy.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.
Related References¶
- API and action errors —
ErrorCode,handleApiError, andActionResult. - Authentication — provider behaviour and account flows.
- Multi-Tenant Administration — host resolution, suspension, custom domains.
- Deployment — production configuration.
- Root rule 120 — Security.
- Root rule 050 — APIs & Well-Known Files.