Headers & CORS

Security headers, CORS configuration, route-aware policies, and API key rotation schedule

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

HeaderDefault ValuePurpose
Strict-Transport-Securitymax-age=31536000; includeSubDomains; preloadForces HTTPS for 1 year, includes subdomains
X-Frame-OptionsSAMEORIGIN (pages) / DENY (API)Prevents clickjacking by controlling iframe embedding
X-Content-Type-OptionsnosniffPrevents browsers from MIME-sniffing responses
X-DNS-Prefetch-ControlonEnables DNS prefetching for linked domains
X-XSS-Protection1; mode=blockLegacy XSS filter for older browsers
Referrer-Policystrict-origin-when-cross-originControls referrer header in cross-origin requests
Permissions-Policycamera=(), microphone=(self), geolocation=(), interest-cohort=(), payment=(), usb=()Restricts browser APIs — microphone allowed for same-origin audio input
X-Permitted-Cross-Domain-PoliciesnonePrevents Adobe Flash/PDF from loading data
Cross-Origin-Opener-Policysame-origin-allow-popupsIsolates browsing context (Spectre protection)
Cross-Origin-Resource-Policysame-site (pages) / same-origin (API)Prevents other origins from reading resources

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:
HeaderPage Routes (/, /dashboard)API Routes (/api/*)
X-Frame-OptionsSAMEORIGIN — allows embedding in same-origin iframesDENY — no iframe embedding at all
Cross-Origin-Resource-Policysame-site — accessible from same sitesame-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

ProfileRoutesAllowed OriginsMethodsCredentialsMax Age
public/api/pricing, /api/health, non-API routes* (any origin)GET, POST, OPTIONSNo24 hours
api/api/* (protected endpoints)ALLOWED_ORIGINS env var or app URLGET, POST, PATCH, DELETE, OPTIONSYes24 hours
webhook/api/webhooks/** (any origin)POST, OPTIONSNo1 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.

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'
}

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

KeyRotation PeriodPriorityService
CLERK_SECRET_KEY90 daysCriticalAuthentication
LEMON_SQUEEZY_API_KEY90 daysCriticalPayments
RESEND_API_KEY90 daysCriticalEmail
UPSTASH_REDIS_REST_TOKEN180 daysStandardRate limiting / caching
BLOB_READ_WRITE_TOKEN180 daysStandardFile storage
OPENAI_API_KEY180 daysStandardAI provider
ANTHROPIC_API_KEY180 daysStandardAI provider
GOOGLE_GENERATIVE_AI_API_KEY180 daysStandardAI 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

  1. Generate a new key in the provider's dashboard
  2. Add the new key to your deployment environment (Vercel, etc.)
  3. Deploy with the new key — the old key remains valid during the transition
  4. Verify the new key works in production (check logs for auth errors)
  5. Revoke the old key in the provider's dashboard
VariableRequiredDefaultPurpose
ALLOWED_ORIGINSNoNEXT_PUBLIC_APP_URLComma-separated list of allowed CORS origins for protected API routes
NEXT_PUBLIC_APP_URLYeshttp://localhost:3000Application URL — used as CORS fallback and in security headers
CRON_SECRETNoSecret for authenticating cron job requests (/api/cron/*)
CLERK_WEBHOOK_SECRETYesSVIX secret for verifying Clerk webhook signatures
LEMON_SQUEEZY_WEBHOOK_SECRETYesSecret for verifying Lemon Squeezy webhook signatures

Key Files

FilePurpose
apps/boilerplate/src/lib/security/security-headers.tsSecurity headers generation and application
apps/boilerplate/src/lib/security/cors-middleware.tsCORS configuration, preflight handling, and response headers
apps/boilerplate/src/middleware.tsMiddleware chain that applies headers and CORS to every request