CORS (Cross-Origin Resource Sharing) is the browser security mechanism that controls which origins can make requests to your API. It is also one of the most misunderstood security controls — developers often cargo-cult configurations from StackOverflow without understanding what they are allowing. The most dangerous CORS misconfiguration I see regularly: `Access-Control-Allow-Origin: *` combined with `Access-Control-Allow-Credentials: true`. This combination violates the CORS spec and modern browsers reject it, but the intent — allowing all origins with credentials — would be a critical vulnerability if it worked. Let me walk through what correct CORS configuration looks like for real production deployments.
CORS is a browser enforcement mechanism. It does NOT protect your API from direct HTTP clients (curl, Postman, server-to-server requests) — those ignore CORS headers entirely. CORS only restricts browser-based cross-origin requests. What it protects against: cross-site request forgery (CSRF) for credentialed requests, malicious sites reading your API responses using the victim's browser session. What it does not protect against: non-browser attackers, API abuse without credentials, or DDoS. Understanding this scope is critical — CORS is one layer in a defense-in-depth stack, not a standalone security control.
The browser CORS spec explicitly forbids `Access-Control-Allow-Origin: *` when `Access-Control-Allow-Credentials: true`. If your API serves cookies or Authorization headers on credentialed cross-origin requests, you must specify the exact origin. This means your CORS configuration must maintain a list of allowed origins and dynamically respond with the requesting origin if it is on the list. Never reflect the Origin header verbatim without checking it against your allowlist — that is the equivalent of `Allow-Origin: *` with credential support.
// NestJS main.ts — correct CORS configuration
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') ?? []
// e.g. ALLOWED_ORIGINS=https://app.yourdomain.com,https://admin.yourdomain.com
app.enableCors({
origin: (requestOrigin, callback) => {
// Allow requests with no origin (mobile apps, curl, Postman)
if (!requestOrigin) return callback(null, true)
if (allowedOrigins.includes(requestOrigin)) {
callback(null, true)
} else {
callback(new Error(`CORS: origin ${requestOrigin} not allowed`))
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
credentials: true, // required for cookies/auth headers
maxAge: 600, // cache preflight for 10 minutes
})
await app.listen(3000)
}
bootstrap()
// ❌ NEVER do this — allows any origin with credentials
// app.enableCors({ origin: '*', credentials: true })
// Browser rejects this combination, but the intent is a critical vuln
// Next.js API route CORS (App Router)
// app/api/data/route.ts
export async function GET(request: Request) {
const origin = request.headers.get('origin') ?? ''
const allowed = process.env.ALLOWED_ORIGINS?.split(',') ?? []
const headers = new Headers()
if (allowed.includes(origin)) {
headers.set('Access-Control-Allow-Origin', origin)
headers.set('Access-Control-Allow-Credentials', 'true')
headers.set('Vary', 'Origin')
}
headers.set('Access-Control-Max-Age', '600')
return Response.json({ data: 'ok' }, { headers })
}
export async function OPTIONS(request: Request) {
const origin = request.headers.get('origin') ?? ''
const allowed = process.env.ALLOWED_ORIGINS?.split(',') ?? []
const headers = new Headers()
if (allowed.includes(origin)) {
headers.set('Access-Control-Allow-Origin', origin)
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
headers.set('Access-Control-Allow-Credentials', 'true')
headers.set('Access-Control-Max-Age', '600')
}
return new Response(null, { status: 204, headers })
}From my experience configuring CORS for ERP APIs: maintain your allowed origins list in an environment variable, not hardcoded. For staging environments, the list includes localhost:3000, localhost:3001, and the staging domain. For production, only the production frontend domains. This prevents the common mistake of deploying staging CORS config to production.
NestJS uses the cors package (Express's cors middleware) under the hood. Configure it in main.ts with explicit origin validation, method limits, and credential handling. The key is the origin callback pattern — it lets you check the requesting origin against your allowlist and return an error for unrecognized origins, rather than allowing all or blocking all.
Preflight requests (OPTIONS) are sent by browsers before credentialed or non-simple cross-origin requests. Each preflight adds a round-trip latency hit. Configure Access-Control-Max-Age to cache preflight results — browsers will not re-send the OPTIONS request for this duration. I use 600 seconds (10 minutes) which reduces preflight overhead significantly for SPA applications that make frequent API calls. The maximum most browsers honor is 7200 seconds (2 hours) for Chrome and 86400 for Firefox.
The CORS misconfiguration that appears in real penetration test reports: using a regex that matches the origin too broadly. I have seen configurations like `origin.includes('mycompany.com')` which would allow `evil-mycompany.com` through. Always use exact string matching or a validated set of origins. Another common error: not restricting allowed methods — if your API only supports GET and POST, do not add PUT, DELETE, and PATCH to Access-Control-Allow-Methods. Each additional method is an additional attack surface.
For Next.js API routes, CORS headers are not set automatically — you need to add them explicitly. The recommended pattern is a CORS middleware wrapper that validates the origin and sets appropriate headers. For App Router API routes (route.ts), add headers to the response in your handler. For Pages Router API routes, use a middleware function. If your Next.js app and API are on the same origin (which they should be in most cases), you do not need CORS at all — CORS only applies to cross-origin requests.
blog.posts.corsConfiguration.content.section4Content
CORS and cookie SameSite attributes work together for credential security. With SameSite=Strict cookies, the browser will not send the cookie on any cross-origin request, making CORS credentials configuration irrelevant for that cookie. With SameSite=Lax (the modern default), cookies are sent on cross-origin top-level navigations but not on background requests — appropriate for most session cookies. With SameSite=None (required for cross-origin cookie sending), you must also set Secure, meaning HTTPS only. The combination I use for ERP APIs: refresh tokens in SameSite=Strict Secure httpOnly cookies (same-origin only), access tokens in memory (not cookies), CORS allowlist for the specific frontend origins.
Sources & Further Reading