The browser CORS spec explicitly forbids this combination. If credentials (cookies or Authorization headers) are involved in cross-origin requests, you must specify the exact requesting origin rather than a wildcard. Modern browsers will reject any response that pairs these two headers.
Origins should be stored in an environment variable rather than hardcoded, and validated using an origin callback that checks the requesting origin against the allowlist. Staging environments should list localhost:3000, localhost:3001, and the staging domain, while production should only include the production frontend domains — this prevents accidentally deploying staging CORS config to production.
It tells browsers how long (in seconds) to cache the result of a preflight OPTIONS request, avoiding a round-trip before every credentialed or non-simple cross-origin call. The post recommends 600 seconds (10 minutes); Chrome honors up to 7200 seconds and Firefox up to 86400 seconds.
Using a substring check such as `origin.includes('mycompany.com')` is dangerous because it would allow `evil-mycompany.com` to pass validation. Always use exact string matching or a pre-validated set of allowed origin strings instead.
Only when the API and the Next.js frontend are on different origins. If they share the same origin — the typical setup — CORS does not apply and no headers need to be set. When cross-origin access is required, you must add the headers explicitly via a middleware wrapper, since Next.js does not set them automatically.
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.
Test CORS from the browser console, not curl. Open your production frontend in a browser, open DevTools, and run: `fetch('https://your-api.com/api/endpoint', {method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({test: true})}).then(r => r.json()).then(console.log)`. If it works from your frontend origin and fails from a different origin (try opening a different tab with about:blank), your CORS is configured correctly. Also test with an explicit attacker origin using a local HTML file that makes requests to your API.
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