How to fix CORS misconfiguration
CORS misconfiguration is the silent multiplier — it doesn't directly leak data, but it amplifies every other authentication and authorization bug. The most common patterns (wildcard with credentials, reflecting any origin, trusting null) ship constantly because copy-pasted dev-mode CORS code makes it to production. Here's the fix.
What is CORS Misconfiguration?
CORS (Cross-Origin Resource Sharing) is the browser security model that controls when JavaScript on origin A can read responses from origin B. By default, browsers block such cross-origin reads — the same-origin policy. CORS is the opt-in that lets origin B say 'origin A is allowed to read my responses' via the Access-Control-Allow-Origin header. Done wrong, CORS turns 'protected by same-origin policy' into 'readable by any malicious site the user visits while logged in'.
The classic exploit: user is logged into bank.com (cookies set). User visits evil.com (an attacker-controlled site). evil.com's JavaScript fetches https://bank.com/api/account-balance with credentials. WITHOUT CORS, the browser sends the cookies but blocks evil.com's JS from reading the response. WITH misconfigured CORS (Access-Control-Allow-Origin: * AND Access-Control-Allow-Credentials: true, or origin reflected without validation), the browser allows evil.com to read the response. Account data ends up at evil.com.
The four fatal CORS patterns are: (1) wildcard with credentials — explicitly forbidden by spec but some servers do it anyway, (2) reflected origin — server echoes whatever Origin header arrives, no validation, (3) null origin — accepting Origin: null which sandboxed iframes and file:// URLs send, (4) trusting any subdomain via regex like /\.example\.com$/ that matches notexample.com or example.com.evil.com. Each leads to read-access to credentialed responses from any origin.
CORS misconfig is high-severity because it's a force multiplier. Combined with stored XSS on a related domain, you get session hijack. Combined with permissive Cookie SameSite settings (None or unset), CSRF protection collapses. Combined with auth-via-bearer-tokens-in-headers and a forgotten preflight, attackers exfiltrate Authorization headers. Fix it and the security boundaries the browser is supposed to enforce actually get enforced.
What an attacker can do
The concrete impact of leaving CORS Misconfiguration unpatched.
Read access to authenticated endpoints from any origin
Any malicious site the user visits while logged in can read protected API responses — account data, PII, session-bound resources.
CSRF amplification
Permissive CORS (especially with credentials) often breaks the assumption that CSRF tokens are safe. Attacker-origin JS can read the CSRF token, then use it.
Bearer token / JWT exfiltration
If your auth uses Authorization: Bearer headers, misconfigured CORS may expose preflight bypasses where attacker JS reads tokens from XHR responses.
Internal admin panel access via subdomain bug
Trusting any subdomain (regex bugs) gives access to admin.example.com from notexample.com.evil.com.
Cache poisoning via Vary mismatch
If your CORS responses don't include Vary: Origin, CDN caches the response with one origin and serves it to another. Crosses tenant boundaries via cache.
OAuth / SSO compromise
Some OAuth flows depend on CORS-protected endpoints. Misconfigured CORS lets attacker site initiate flows that should require user-origin context.
How do I know if I'm vulnerable?
Manual: open DevTools, send a fetch to your API from a different origin (paste this in the console of any random site you have open): fetch('https://your-api.com/protected-endpoint', { credentials: 'include' }).then(r => r.text()).then(console.log). If you get the response data, your CORS is misconfigured for credentialed requests. Repeat with Origin: null in the request (using a sandboxed iframe via data: URL) — many servers accept null origin without realizing.
Automated: AuditCore's CORS scanner tests 12 attack patterns: wildcard with credentials, reflected origin, regex bugs (notexample.com, example.com.evil.com), null origin, http-with-credentials (downgrade), missing Vary header, preflight bypasses, and the canonical 'Trojan-Horse' subdomain attack. Free Trial covers basic CORS on the homepage; Pro/Business runs across full crawl.
Code review pattern: search for CORS configuration. In Express: app.use(cors(...)) — check what origin function returns. In Next.js: middleware that sets Access-Control-Allow-Origin headers. In FastAPI: CORSMiddleware allow_origins=. In Spring: @CrossOrigin and CorsConfiguration. In nginx: add_header Access-Control-Allow-Origin. The common patterns to flag: '*' anywhere, regex matching, blindly returning req.headers.origin, allowing 'null'.
How to fix CORS Misconfiguration
6 ordered steps. Apply them in order — each builds on the previous.
- 1
Decide: do you actually need CORS?
If your API is only consumed by your own origin (same-origin SPAs), you don't need CORS at all. The safest CORS config is 'no CORS headers'.
Many APIs that ship CORS don't actually need it — it was added 'just in case'. If your frontend is at app.example.com and your API is at api.example.com, you DO need CORS. If your frontend is at example.com and your API is at example.com/api, you DON'T need CORS. Remove the headers entirely; the browser's same-origin policy gives you protection for free.
ExpressStep 1// If frontend and API are on the same origin, just don't use cors(): import express from 'express'; const app = express(); app.get('/api/me', requireAuth, async (req, res) => { res.json({ user: req.user }); }); // No app.use(cors(...)) — browser same-origin policy protects you for free - 2
Use an explicit allow-list of origins — never wildcard with credentials
If you do need CORS, allow exactly the origins that need it. Listing them as strings (no regex). Never combine '*' with credentials.
Wildcard origin (Access-Control-Allow-Origin: *) is OK for public APIs that don't use credentials (no cookies, no auth header). For ANYTHING credentialed, use specific origins. The CORS spec explicitly forbids wildcard + credentials and modern browsers reject it — but some servers still set both because of bad copy-paste, and CDN/proxy layers sometimes recombine headers in weird ways. Never set both.
Express / cors middlewareStep 2import cors from 'cors'; const ALLOWED_ORIGINS = new Set([ 'https://app.yourdomain.com', 'https://www.yourdomain.com', ]); app.use(cors({ origin: (origin, callback) => { // No origin = curl/server-side request → allow without CORS headers if (!origin) return callback(null, true); // Strict allow-list — exact match only, no regex if (ALLOWED_ORIGINS.has(origin)) return callback(null, origin); return callback(new Error('CORS: origin not allowed')); }, credentials: true, methods: ['GET','POST','PUT','PATCH','DELETE'], allowedHeaders: ['Content-Type','Authorization'], maxAge: 600, })); - 3
Reject Origin: null explicitly
Sandboxed iframes (sandbox attribute), file:// URLs, and data: URIs all send Origin: null. Never accept null as an allowed origin.
Origin: null is the silent ambush. Many CORS configs that allow-list specific origins forget to explicitly reject null. Attacker hosts a sandbox iframe on a static site (or even a saved HTML file the user opens locally), iframe makes the cross-origin request, browser sends Origin: null, server echoes 'null' as Allow-Origin, attack succeeds. Always check origin !== 'null' before adding it to the response.
FastAPIStep 3from fastapi.middleware.cors import CORSMiddleware # Explicit list — null is NOT included app.add_middleware( CORSMiddleware, allow_origins=[ "https://app.yourdomain.com", "https://www.yourdomain.com", ], allow_credentials=True, allow_methods=["GET","POST","PUT","PATCH","DELETE"], allow_headers=["Content-Type","Authorization"], max_age=600, ) # Note: FastAPI's CORSMiddleware does not accept "null" by default # unless you add it explicitly — don't. - 4
Use exact-match comparison, NOT regex or endsWith
Regex like /\.example\.com$/ is a constant source of CORS bugs. notexample.com matches. example.com.evil.com matches. Use exact-string allow-lists.
Common bug: regex /\.example\.com$/.test(origin). Looks fine. Fails: 'notexample.com' (no leading dot in regex). 'example.com' on its own. Worse: regex /example\.com$/ matches 'example.com.evil.com' if you forgot the $. The fix is exact-string membership testing — Set.has() in JS, in operator in Python, ImmutableSet.contains in Java. No regex. No endsWith. No clever string operations.
Express — wrong vs rightStep 4// VULNERABLE — regex bugs const ALLOWED = /\.yourdomain\.com$/; function isAllowed(origin) { return ALLOWED.test(origin); // Matches: app.yourdomain.com ✓ // Matches: notyourdomain.com ✗ (no leading dot in regex) // Matches: yourdomain.com.evil.com ✗ ($ at end of "evil.com" not yourdomain.com) } // SECURE — exact-string allow-list const ALLOWED = new Set([ 'https://app.yourdomain.com', 'https://www.yourdomain.com', 'https://admin.yourdomain.com', ]); function isAllowed(origin) { return ALLOWED.has(origin); // exact match, no edge cases } - 5
Always set Vary: Origin to prevent cache poisoning
If your server returns different Allow-Origin values for different request origins, Vary: Origin tells caches not to confuse them.
Without Vary: Origin, CDNs and proxies cache the response with the first request's Allow-Origin and serve it to other origins. CDN sees 'Allow-Origin: app.example.com' first, caches the response, serves it to a request from evil.com — the cached header now allows evil.com. Vary: Origin tells the cache 'response varies based on Origin header — separate cache entry per origin'. Most CORS middleware sets this automatically, but custom CORS code often forgets.
nginxStep 5location /api/ { # Allow-list with map for clean syntax set $cors_origin ""; if ($http_origin = "https://app.yourdomain.com") { set $cors_origin $http_origin; } if ($http_origin = "https://www.yourdomain.com") { set $cors_origin $http_origin; } # Set CORS headers ONLY if origin is in the allow-list add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials true always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always; add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; # CRITICAL — prevents cache from confusing origins add_header Vary "Origin" always; proxy_pass http://backend; } - 6
Add unit tests for CORS attack patterns
Test that attacker-controlled origins, null origin, regex-confusion strings get NO CORS allow header.
Test pattern: send OPTIONS preflight + GET requests with various Origin headers — your domain (allowed), random domain (rejected), null (rejected), notexample.com (rejected), yourdomain.com.evil.com (rejected). Assert Access-Control-Allow-Origin is either absent or matches the request origin only when allow-listed.
Jest / supertestStep 6const ALLOWED = 'https://app.yourdomain.com'; const ATTACKS = [ 'https://evil.com', 'null', 'https://notyourdomain.com', 'https://yourdomain.com.evil.com', 'https://yourdomain.evil.com', 'http://app.yourdomain.com', // http downgrade '', ]; describe('CORS allow-list enforcement', () => { it('allows the legitimate origin', async () => { const res = await api.get('/api/me') .set('Origin', ALLOWED); expect(res.headers['access-control-allow-origin']).toBe(ALLOWED); }); describe.each(ATTACKS)('rejects attack origin %s', (attackOrigin) => { it('does NOT echo the origin in Access-Control-Allow-Origin', async () => { const res = await api.get('/api/me') .set('Origin', attackOrigin); const acao = res.headers['access-control-allow-origin']; expect([undefined, ALLOWED, 'null'].includes(acao)).toBe(false); // i.e. the server didn't blindly echo the attacker origin if (acao) expect(acao).not.toBe(attackOrigin); }); }); });
How to verify the fix
Manual: open DevTools on a random site (cnn.com, your blog, anywhere). Run fetch('https://your-api.com/protected', { credentials: 'include' }).then(r => r.text()).then(console.log). Should fail (browser blocks). If you get response data back, your CORS is misconfigured. Repeat with Origin: null — set up a sandboxed iframe via data:text/html,<script>fetch(...)</script> and check response.
Automated: AuditCore's CORS scanner runs 12 attack patterns including the canonical Trojan-Horse subdomain bypass. Pro and Business tiers cover the full attack matrix. Free Trial covers the basic patterns on your homepage.
Long-term: keep step 6 unit tests in CI. Add Cloudflare/CDN-level monitoring for unusual Origin headers — sudden spikes of 'evil.com' or null Origins reaching your origin server suggest active probing. Set up logging that records the full Origin header on credentialed requests.
Frequently asked questions
Is wildcard CORS (Access-Control-Allow-Origin: *) ever safe?+
Yes — for public, non-credentialed APIs. Public CDN endpoints, public read-only APIs, anonymous content APIs are fine with wildcard. The rule: NEVER combine wildcard with Access-Control-Allow-Credentials: true. The CORS spec forbids this combination, modern browsers reject it, but some custom server code emits both headers unconditionally — leading to bugs.
Why is reflecting the Origin header dangerous?+
If your server reads req.headers.origin and echoes it as Access-Control-Allow-Origin, you've effectively allowed every origin. Attacker sends Origin: evil.com, server echoes Allow-Origin: evil.com, browser allows evil.com to read response. This is the most common CORS bug — it's the lazy 'just allow whatever they ask for' pattern. Fix: always validate against an explicit allow-list before echoing.
What's the deal with Origin: null?+
Sandboxed iframes (with sandbox attribute), file:// URLs, and certain data: URIs send Origin: null. Most apps don't expect null and don't handle it. Some CORS configs add 'null' to allow-list 'just to be safe' or 'for development' — making the entire allow-list pointless because attackers can trigger null origin from any sandboxed context. Never accept null. Period.
Should I use Cookie SameSite or CORS for CSRF protection?+
Both, layered. SameSite=Strict (or Lax with judgement) on session cookies prevents most CSRF without any CORS involvement. CORS is the secondary layer — restricts WHICH origins can read responses even if cookies somehow attach. SameSite=None requires Secure attribute and works only over HTTPS. The combination of SameSite + CORS allow-list + CSRF tokens is what modern apps run.
Is CORS the same as CSP?+
No, different purposes. CORS controls WHO can read responses from your server (cross-origin). CSP controls WHAT YOUR PAGE can load and execute (script-src, frame-src, etc.). They complement each other. Misconfigured CORS exposes your data; misconfigured CSP exposes your users to XSS. Both matter.
What's a CORS preflight and when does it run?+
Browsers send a preflight OPTIONS request before 'unsafe' methods (POST with non-simple Content-Type, PUT, DELETE, custom headers like Authorization). Preflight checks if the actual request is allowed via Access-Control-Allow-Methods and Allow-Headers. Misconfigured preflight (allowing all methods/headers) defeats CORS protection. Set Access-Control-Allow-Methods to exactly what you support, Allow-Headers to specific headers (Content-Type, Authorization), Max-Age to 600 to cache the preflight.
How do I handle CORS in Next.js?+
Three options. (1) For API routes: set headers in the route handler or a middleware. (2) Configure allowed origins in next.config.js's headers() function for static routing. (3) Use a CORS package like 'cors' if you have complex logic. For App Router with Route Handlers, return new Response with the headers explicitly. Always test with the steps in this guide.
Will CORS break my mobile app or curl client?+
No — CORS is enforced ONLY by web browsers. Mobile apps (iOS, Android), curl, server-to-server requests, Postman — all bypass CORS. CORS is specifically the browser's same-origin policy mediator. If your API works in curl but fails in browser JS, it's a CORS issue. If it fails in curl too, it's something else.