Kit applies 10 security headers and route-aware CORS policies to every response via the middleware chain. These form the outermost defense layer — they protect against clickjacking, MIME sniffing, protocol downgrade attacks, and unauthorized cross-origin requests before your application code even runs.
For the overall security architecture, see Security Overview. For rate limiting and input validation, see Rate Limiting & Validation.
Security Headers
Every response includes a comprehensive set of security headers. The defaults follow industry best practices and OWASP recommendations:
src/lib/security/security-headers.ts — Default Configuration
const DEFAULT_CONFIG: Required<SecurityHeadersConfig> = {
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
xFrameOptions: 'SAMEORIGIN',
noSniff: true,
dnsPrefetchControl: 'on',
referrerPolicy: 'strict-origin-when-cross-origin',
permissionsPolicy: {
camera: [],
microphone: ['self'],
geolocation: [],
'interest-cohort': [], // Disable FLoC
payment: [],
usb: [],
},
}
Header Reference
| Header | Default Value | Purpose |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | Forces HTTPS for 1 year, includes subdomains |
X-Frame-Options | SAMEORIGIN (pages) / DENY (API) | Prevents clickjacking by controlling iframe embedding |
X-Content-Type-Options | nosniff | Prevents browsers from MIME-sniffing responses |
X-DNS-Prefetch-Control | on | Enables DNS prefetching for linked domains |
X-XSS-Protection | 1; mode=block | Legacy XSS filter for older browsers |
Referrer-Policy | strict-origin-when-cross-origin | Controls referrer header in cross-origin requests |
Permissions-Policy | camera=(), microphone=(self), geolocation=(), interest-cohort=(), payment=(), usb=() | Restricts browser APIs — microphone allowed for same-origin audio input |
X-Permitted-Cross-Domain-Policies | none | Prevents Adobe Flash/PDF from loading data |
Cross-Origin-Opener-Policy | same-origin-allow-popups | Isolates browsing context (Spectre protection) |
Cross-Origin-Resource-Policy | same-site (pages) / same-origin (API) | Prevents other origins from reading resources |
The
Strict-Transport-Security header is only added when NODE_ENV=production and the request uses HTTPS. This prevents issues with local development over HTTP. In production with HTTPS, HSTS is always active.Route-Aware Headers
Some headers apply different policies depending on the route type. API routes get stricter settings since they should never be embedded or accessed from external origins:
| Header | Page Routes (/, /dashboard) | API Routes (/api/*) |
|---|---|---|
X-Frame-Options | SAMEORIGIN — allows embedding in same-origin iframes | DENY — no iframe embedding at all |
Cross-Origin-Resource-Policy | same-site — accessible from same site | same-origin — only accessible from exact same origin |
This distinction ensures that your marketing pages and dashboard can use iframes internally (e.g., for embedded widgets), while API endpoints are locked down completely.
src/lib/security/security-headers.ts — Route-Aware X-Frame-Options
// 2. X-Frame-Options
// API routes: DENY (no framing)
// Other routes: SAMEORIGIN (same-origin framing only)
if (pathname.startsWith('/api/')) {
headers.set('X-Frame-Options', 'DENY')
} else {
const xFrameOptions = mergedConfig.xFrameOptions
if (typeof xFrameOptions === 'string') {
headers.set('X-Frame-Options', xFrameOptions)
} else {
headers.set('X-Frame-Options', `ALLOW-FROM ${xFrameOptions.allow}`)
}
}
CORS Configuration
Cross-Origin Resource Sharing (CORS) controls which external domains can make requests to your API. Kit defines three CORS profiles based on route type:
src/lib/security/cors-middleware.ts — CORS Configurations
const CORS_CONFIGS: Record<string, CORSConfig> = {
// Public API routes - Open CORS for public endpoints
public: {
allowedOrigins: ['*'],
allowedMethods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: false,
maxAge: 86400, // 24 hours
},
// Protected API routes - Controlled origins
api: {
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || [
process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
],
allowedMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400,
},
// Webhook routes - Accept from any origin (webhooks don't send Origin header)
webhook: {
allowedOrigins: ['*'],
allowedMethods: ['POST', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-Signature',
'Webhook-Signature',
'Svix-Id',
'Svix-Timestamp',
'Svix-Signature',
],
credentials: false,
maxAge: 3600, // 1 hour
},
}
CORS Profile Summary
| Profile | Routes | Allowed Origins | Methods | Credentials | Max Age |
|---|---|---|---|---|---|
| public | /api/pricing, /api/health, non-API routes | * (any origin) | GET, POST, OPTIONS | No | 24 hours |
| api | /api/* (protected endpoints) | ALLOWED_ORIGINS env var or app URL | GET, POST, PATCH, DELETE, OPTIONS | Yes | 24 hours |
| webhook | /api/webhooks/* | * (any origin) | POST, OPTIONS | No | 1 hour |
How Route Type Detection Works
The CORS middleware determines the profile by checking the request pathname in order:
src/lib/security/cors-middleware.ts — Route Type Detection
function getRouteType(pathname: string): keyof typeof CORS_CONFIGS {
if (pathname.startsWith('/api/webhooks/')) {
return 'webhook'
}
if (
pathname.startsWith('/api/pricing') ||
pathname.startsWith('/api/health')
) {
return 'public'
}
if (pathname.startsWith('/api/')) {
return 'api'
}
return 'public' // Default to public for non-API routes
}
The first matching rule wins. Webhook routes are checked before general API routes because
/api/webhooks/ is a subset of /api/.CORS Environment Variables
Protected API routes use the
ALLOWED_ORIGINS environment variable to determine which domains can make cross-origin requests:bash
# Single origin
ALLOWED_ORIGINS=https://myapp.com
# Multiple origins (comma-separated)
ALLOWED_ORIGINS=https://myapp.com,https://staging.myapp.com,https://admin.myapp.com
# Wildcard subdomains are supported in the origin matching
# (configured in code, not via env var)
When
ALLOWED_ORIGINS is not set, the system falls back to NEXT_PUBLIC_APP_URL (or http://localhost:3000 if that is also missing). This means local development works out of the box without any CORS configuration.During local development, CORS is not an issue because browser requests to
localhost:3000 from localhost:3000 are same-origin. CORS only applies to cross-origin requests — for example, a frontend on localhost:3001 calling an API on localhost:3000. To test CORS locally, run your frontend and API on different ports.Preflight Handling
The CORS middleware intercepts
OPTIONS requests (CORS preflight) and returns a 204 No Content response with the appropriate CORS headers. This short-circuits the middleware chain — the request never reaches Clerk authentication or your route handler:OPTIONS /api/upload HTTP/1.1
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
──────────────────────────────────
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Vary: Origin
The
Vary: Origin header ensures caches store separate responses for different origins. The Max-Age of 86400 seconds (24 hours) means browsers cache the preflight result, reducing the number of preflight requests.Adding CORS for New Routes
When you add new API routes, they automatically inherit the correct CORS profile based on the path:
/api/webhooks/my-service— Gets the webhook profile (open CORS, POST only)/api/my-feature— Gets the api profile (restricted origins, credentials)/api/health-check— Gets the api profile unless you add it to the public list
To make a new route use the public profile, add it to the detection function:
typescript
// In apps/boilerplate/src/lib/security/cors-middleware.ts
function getRouteType(pathname: string): keyof typeof CORS_CONFIGS {
if (pathname.startsWith('/api/webhooks/')) return 'webhook'
if (
pathname.startsWith('/api/pricing') ||
pathname.startsWith('/api/health') ||
pathname.startsWith('/api/my-public-endpoint') // Add here
) {
return 'public'
}
if (pathname.startsWith('/api/')) return 'api'
return 'public'
}
Public CORS routes allow requests from any origin without credentials. Do not add authenticated endpoints to the public CORS profile — the
credentials: false setting means cookies and auth headers are not sent with cross-origin requests, which would break authentication.API Key Management
Kit uses multiple third-party API keys that should be rotated on a regular schedule to minimize the blast radius of compromised credentials:
Rotation Schedule
| Key | Rotation Period | Priority | Service |
|---|---|---|---|
CLERK_SECRET_KEY | 90 days | Critical | Authentication |
LEMON_SQUEEZY_API_KEY | 90 days | Critical | Payments |
RESEND_API_KEY | 90 days | Critical | |
UPSTASH_REDIS_REST_TOKEN | 180 days | Standard | Rate limiting / caching |
BLOB_READ_WRITE_TOKEN | 180 days | Standard | File storage |
OPENAI_API_KEY | 180 days | Standard | AI provider |
ANTHROPIC_API_KEY | 180 days | Standard | AI provider |
GOOGLE_GENERATIVE_AI_API_KEY | 180 days | Standard | AI provider |
Critical keys (90-day rotation) are keys that, if compromised, could directly lead to financial loss or user data exposure. Standard keys (180-day rotation) have a narrower scope of impact.
Rotation Process
- Generate a new key in the provider's dashboard
- Add the new key to your deployment environment (Vercel, etc.)
- Deploy with the new key — the old key remains valid during the transition
- Verify the new key works in production (check logs for auth errors)
- Revoke the old key in the provider's dashboard
Most providers allow multiple active API keys simultaneously. This enables zero-downtime rotation: add the new key, deploy, verify, then revoke the old one. Never revoke the old key before confirming the new one works in production.
Security-Related Environment Variables
| Variable | Required | Default | Purpose |
|---|---|---|---|
ALLOWED_ORIGINS | No | NEXT_PUBLIC_APP_URL | Comma-separated list of allowed CORS origins for protected API routes |
NEXT_PUBLIC_APP_URL | Yes | http://localhost:3000 | Application URL — used as CORS fallback and in security headers |
CRON_SECRET | No | — | Secret for authenticating cron job requests (/api/cron/*) |
CLERK_WEBHOOK_SECRET | Yes | — | SVIX secret for verifying Clerk webhook signatures |
LEMON_SQUEEZY_WEBHOOK_SECRET | Yes | — | Secret for verifying Lemon Squeezy webhook signatures |
Key Files
| File | Purpose |
|---|---|
apps/boilerplate/src/lib/security/security-headers.ts | Security headers generation and application |
apps/boilerplate/src/lib/security/cors-middleware.ts | CORS configuration, preflight handling, and response headers |
apps/boilerplate/src/middleware.ts | Middleware chain that applies headers and CORS to every request |