Security — CORS & CSRF
CORS (Cross-Origin Resource Sharing) and CSRF (Cross-Site Request Forgery) are two distinct but related security concerns in web applications. CORS controls which external origins can access your resources, while CSRF protects against unauthorized commands from authenticated users.
Misconfiguring either can leave your application vulnerable to data theft, unauthorized actions, or cross-origin attacks. This guide covers the fundamentals and practical configuration for both.
The Same-Origin Policy (SOP) is the browser's fundamental security mechanism. It restricts how a document or script loaded from one origin can interact with resources from another origin. An origin is defined by the scheme (protocol), host (domain), and port.
| URL | Same Origin? | Reason |
|---|---|---|
| https://example.com/page1 | Yes | Same scheme, host, port |
| https://api.example.com | No | Different host (subdomain) |
| http://example.com | No | Different scheme |
| https://example.com:8080 | No | Different port |
info
CORS uses HTTP headers to relax the Same-Origin Policy. The server specifies which origins, methods, and headers are allowed. The browser enforces these rules.
| 1 | // CORS response headers explained |
| 2 | // Access-Control-Allow-Origin: Which origins are allowed |
| 3 | Access-Control-Allow-Origin: https://example.com |
| 4 | // Access-Control-Allow-Origin: * (allow any origin — use with caution) |
| 5 | |
| 6 | // Access-Control-Allow-Methods: Allowed HTTP methods |
| 7 | Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS |
| 8 | |
| 9 | // Access-Control-Allow-Headers: Allowed request headers |
| 10 | Access-Control-Allow-Headers: Content-Type, Authorization, X-CSRF-Token |
| 11 | |
| 12 | // Access-Control-Expose-Headers: Which response headers the client can read |
| 13 | Access-Control-Expose-Headers: X-RateLimit-Remaining, X-Request-Id |
| 14 | |
| 15 | // Access-Control-Allow-Credentials: Allow cookies/auth headers |
| 16 | Access-Control-Allow-Credentials: true |
| 17 | |
| 18 | // Access-Control-Max-Age: How long to cache the preflight response (seconds) |
| 19 | Access-Control-Max-Age: 86400 |
danger
For requests that are not "simple" (e.g., custom headers, non-standard content types, or methods other than GET/POST), the browser sends a preflight OPTIONS request before the actual request. The server must respond correctly for the browser to proceed.
| 1 | // Preflight OPTIONS request (automatically sent by browser) |
| 2 | OPTIONS /api/data HTTP/1.1 |
| 3 | Origin: https://app.example.com |
| 4 | Access-Control-Request-Method: POST |
| 5 | Access-Control-Request-Headers: Content-Type, Authorization |
| 6 | |
| 7 | // Server response to preflight |
| 8 | HTTP/1.1 204 No Content |
| 9 | Access-Control-Allow-Origin: https://app.example.com |
| 10 | Access-Control-Allow-Methods: GET, POST, PUT, DELETE |
| 11 | Access-Control-Allow-Headers: Content-Type, Authorization |
| 12 | Access-Control-Max-Age: 86400 |
| 13 | |
| 14 | // Express middleware handling preflight globally |
| 15 | app.options('*', cors()); // Enable preflight for all routes |
| 16 | |
| 17 | // Or handle manually |
| 18 | app.use((req, res, next) => { |
| 19 | res.header('Access-Control-Allow-Origin', 'https://app.example.com'); |
| 20 | res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); |
| 21 | res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); |
| 22 | if (req.method === 'OPTIONS') { |
| 23 | return res.sendStatus(204); |
| 24 | } |
| 25 | next(); |
| 26 | }); |
The cors package in Express makes it straightforward to configure CORS. Use environment-based configuration to allow different origins in development vs production.
| 1 | const cors = require('cors'); |
| 2 | const express = require('express'); |
| 3 | const app = express(); |
| 4 | |
| 5 | // Development — allow all origins |
| 6 | if (process.env.NODE_ENV === 'development') { |
| 7 | app.use(cors({ origin: true, credentials: true })); |
| 8 | } |
| 9 | |
| 10 | // Production — restrict to specific origins |
| 11 | const allowedOrigins = [ |
| 12 | 'https://example.com', |
| 13 | 'https://www.example.com', |
| 14 | 'https://admin.example.com', |
| 15 | ]; |
| 16 | |
| 17 | app.use(cors({ |
| 18 | origin: (origin, callback) => { |
| 19 | // Allow requests with no origin (server-to-server, curl, etc.) |
| 20 | if (!origin) return callback(null, true); |
| 21 | if (allowedOrigins.includes(origin)) { |
| 22 | callback(null, true); |
| 23 | } else { |
| 24 | callback(new Error('Not allowed by CORS')); |
| 25 | } |
| 26 | }, |
| 27 | methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], |
| 28 | allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'], |
| 29 | credentials: true, |
| 30 | maxAge: 86400, |
| 31 | })); |
| 32 | |
| 33 | // Per-route CORS |
| 34 | const corsOptions = { |
| 35 | origin: 'https://trusted-third-party.com', |
| 36 | methods: ['GET'], |
| 37 | }; |
| 38 | |
| 39 | app.get('/api/public', cors(corsOptions), (req, res) => { |
| 40 | res.json({ data: 'Public data' }); |
| 41 | }); |
best practice
In Next.js, CORS headers must be set manually in API route handlers or via middleware. The App Router provides access to the full Response API for setting headers.
| 1 | // App Router — API route with CORS (app/api/data/route.ts) |
| 2 | import { NextRequest, NextResponse } from 'next/server'; |
| 3 | |
| 4 | const allowedOrigins = ['https://example.com', 'https://app.example.com']; |
| 5 | |
| 6 | function getCorsHeaders(origin: string | null) { |
| 7 | const isAllowed = origin && allowedOrigins.includes(origin); |
| 8 | return { |
| 9 | 'Access-Control-Allow-Origin': isAllowed ? origin : '', |
| 10 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', |
| 11 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', |
| 12 | 'Access-Control-Allow-Credentials': 'true', |
| 13 | 'Access-Control-Max-Age': '86400', |
| 14 | }; |
| 15 | } |
| 16 | |
| 17 | export async function OPTIONS(request: NextRequest) { |
| 18 | const origin = request.headers.get('origin'); |
| 19 | return NextResponse.json( |
| 20 | {}, |
| 21 | { status: 204, headers: getCorsHeaders(origin) } |
| 22 | ); |
| 23 | } |
| 24 | |
| 25 | export async function GET(request: NextRequest) { |
| 26 | const origin = request.headers.get('origin'); |
| 27 | const data = await fetchData(); |
| 28 | return NextResponse.json(data, { |
| 29 | headers: getCorsHeaders(origin), |
| 30 | }); |
| 31 | } |
| 32 | |
| 33 | // Pages Router — API route with CORS |
| 34 | export default function handler(req, res) { |
| 35 | const origin = req.headers.origin; |
| 36 | const allowedOrigin = allowedOrigins.includes(origin) ? origin : ''; |
| 37 | |
| 38 | res.setHeader('Access-Control-Allow-Origin', allowedOrigin); |
| 39 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); |
| 40 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); |
| 41 | res.setHeader('Access-Control-Allow-Credentials', 'true'); |
| 42 | |
| 43 | if (req.method === 'OPTIONS') { |
| 44 | res.status(200).end(); |
| 45 | return; |
| 46 | } |
| 47 | |
| 48 | // Handle the request |
| 49 | res.status(200).json({ message: 'Success' }); |
| 50 | } |
nginx can add CORS headers at the reverse proxy layer, which is useful when your application server doesn't handle CORS natively or you want a centralized configuration.
| 1 | # nginx CORS configuration |
| 2 | server { |
| 3 | listen 443 ssl; |
| 4 | server_name api.example.com; |
| 5 | |
| 6 | # Handle preflight requests |
| 7 | if ($request_method = 'OPTIONS') { |
| 8 | add_header Access-Control-Allow-Origin $http_origin; |
| 9 | add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS'; |
| 10 | add_header Access-Control-Allow-Headers 'Content-Type, Authorization, X-CSRF-Token'; |
| 11 | add_header Access-Control-Allow-Credentials 'true'; |
| 12 | add_header Access-Control-Max-Age 86400; |
| 13 | add_header Content-Length 0; |
| 14 | add_header Content-Type text/plain; |
| 15 | return 204; |
| 16 | } |
| 17 | |
| 18 | # Handle actual requests |
| 19 | location /api/ { |
| 20 | add_header Access-Control-Allow-Origin $http_origin; |
| 21 | add_header Access-Control-Allow-Credentials 'true'; |
| 22 | |
| 23 | if ($http_origin !~ '^https://(example\.com|app\.example\.com)$') { |
| 24 | add_header Access-Control-Allow-Origin ''; # Block unauthorized origins |
| 25 | } |
| 26 | |
| 27 | proxy_pass http://backend:3000; |
| 28 | proxy_set_header Host $host; |
| 29 | proxy_set_header X-Real-IP $remote_addr; |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | # CORS with wildcard (public API — no credentials) |
| 34 | server { |
| 35 | listen 443 ssl; |
| 36 | server_name api.public.example.com; |
| 37 | |
| 38 | location / { |
| 39 | add_header Access-Control-Allow-Origin '*'; |
| 40 | add_header Access-Control-Allow-Methods 'GET'; |
| 41 | # Do NOT set Allow-Credentials with wildcard |
| 42 | proxy_pass http://backend:3000; |
| 43 | } |
| 44 | } |
Serverless platforms like Cloudflare Workers, AWS Lambda, and Vercel Edge Functions require explicit CORS handling since they act as both the application and the edge.
| 1 | // Cloudflare Worker CORS handler |
| 2 | addEventListener('fetch', event => { |
| 3 | event.respondWith(handleRequest(event.request)); |
| 4 | }); |
| 5 | |
| 6 | async function handleRequest(request) { |
| 7 | const origin = request.headers.get('Origin'); |
| 8 | const allowedOrigins = ['https://example.com', 'https://app.example.com']; |
| 9 | const corsOrigin = allowedOrigins.includes(origin) ? origin : ''; |
| 10 | |
| 11 | // Handle preflight |
| 12 | if (request.method === 'OPTIONS') { |
| 13 | return new Response(null, { |
| 14 | headers: { |
| 15 | 'Access-Control-Allow-Origin': corsOrigin, |
| 16 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', |
| 17 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', |
| 18 | 'Access-Control-Max-Age': '86400', |
| 19 | }, |
| 20 | }); |
| 21 | } |
| 22 | |
| 23 | const response = await fetch(request); |
| 24 | const corsResponse = new Response(response.body, response); |
| 25 | corsResponse.headers.set('Access-Control-Allow-Origin', corsOrigin); |
| 26 | corsResponse.headers.set('Access-Control-Allow-Credentials', 'true'); |
| 27 | |
| 28 | return corsResponse; |
| 29 | } |
| 30 | |
| 31 | // AWS Lambda + API Gateway (serverless.yml) |
| 32 | // Add CORS in the API Gateway configuration: |
| 33 | // Api: |
| 34 | // Cors: |
| 35 | // AllowOrigin: "'https://example.com'" |
| 36 | // AllowHeaders: "'Content-Type,Authorization'" |
| 37 | // AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" |
| 38 | // AllowCredentials: "'true'" |
CSRF attacks trick an authenticated user into submitting an unwanted request. The attack succeeds because the browser automatically includes cookies (including session cookies) with every request to the target domain. CSRF protection ensures that requests originate from the actual application, not an external site.
warning
CSRF tokens are unique, unpredictable values generated by the server and included in forms or API requests. The server validates the token before processing state-changing requests.
| 1 | // Server-side CSRF token generation and validation (Express) |
| 2 | const crypto = require('crypto'); |
| 3 | const csrf = require('csurf'); |
| 4 | const express = require('express'); |
| 5 | const app = express(); |
| 6 | |
| 7 | // Using csurf middleware (Express 4) |
| 8 | const csrfProtection = csrf({ cookie: true }); |
| 9 | app.use(csrfProtection); |
| 10 | |
| 11 | // Generate token and pass to template |
| 12 | app.get('/form', (req, res) => { |
| 13 | res.render('form', { csrfToken: req.csrfToken() }); |
| 14 | }); |
| 15 | |
| 16 | // The middleware automatically validates CSRF on POST, PUT, PATCH, DELETE |
| 17 | app.post('/transfer', (req, res) => { |
| 18 | // If CSRF token is invalid, middleware throws a ForbiddenError |
| 19 | res.send('Transfer completed'); |
| 20 | }); |
| 21 | |
| 22 | // Custom CSRF implementation |
| 23 | const tokens = new Map(); |
| 24 | |
| 25 | function generateToken(sessionId) { |
| 26 | const token = crypto.randomBytes(32).toString('hex'); |
| 27 | tokens.set(token, { sessionId, expires: Date.now() + 3600000 }); |
| 28 | return token; |
| 29 | } |
| 30 | |
| 31 | function validateToken(token, sessionId) { |
| 32 | const stored = tokens.get(token); |
| 33 | if (!stored) return false; |
| 34 | if (stored.sessionId !== sessionId) return false; |
| 35 | if (Date.now() > stored.expires) { |
| 36 | tokens.delete(token); |
| 37 | return false; |
| 38 | } |
| 39 | tokens.delete(token); // Single-use token |
| 40 | return true; |
| 41 | } |
| 42 | |
| 43 | // Frontend — include CSRF token in every state-changing request |
| 44 | // <form action="/transfer" method="POST"> |
| 45 | // <input type="hidden" name="_csrf" value="<%= csrfToken %>"> |
| 46 | // <input type="text" name="amount"> |
| 47 | // <button type="submit">Transfer</button> |
| 48 | // </form> |
| 49 | |
| 50 | // For API requests, include token in header |
| 51 | fetch('/api/transfer', { |
| 52 | method: 'POST', |
| 53 | headers: { |
| 54 | 'Content-Type': 'application/json', |
| 55 | 'X-CSRF-Token': csrfToken, |
| 56 | }, |
| 57 | body: JSON.stringify({ amount: 100 }), |
| 58 | }); |
CORS and CSRF are often confused because both involve cross-origin requests. Understanding the distinction is critical for secure configuration.
| Aspect | CORS | CSRF |
|---|---|---|
| What it protects | Reading data cross-origin | Writing data cross-origin |
| Enforcement point | Browser | Server |
| Attack type | Information disclosure | Unauthorized state change |
| Auth involved | May or may not include credentials | Requires authentication (session cookie) |
| Primary defense | Server response headers | CSRF tokens, SameSite cookies |
- Set SameSite=Lax as the default for session cookies — it prevents CSRF in most scenarios without breaking user experience.
- Use SameSite=Strict for sensitive operations like password changes and money transfers.
- Always use CSRF tokens as a defense-in-depth measure, even with SameSite cookies (older browsers may ignore SameSite).
- Never use Access-Control-Allow-Origin: * with credentials. Always whitelist specific origins in production.
- Cache preflight responses with Access-Control-Max-Age to reduce latency, but not longer than 24 hours.
- Validate the Origin header on the server — do not rely solely on the Referer header, which can be missing or spoofed.
- Implement CSRF protection as middleware applied globally to all state-changing routes (POST, PUT, PATCH, DELETE).
- Use custom request headers (X-Requested-By, X-CSRF-Token) as an additional CSRF defense for API requests.
info