32. CORS Configuration
About this chapter
Configure Cross-Origin Resource Sharing (CORS) to allow frontend applications to access your API while maintaining security through carefully defined origin policies.
- CORS fundamentals: Understanding same-origin policy and why CORS is needed
- Origins and scheme: How browsers define origin (scheme, domain, port)
- CORS headers: Access-Control-Allow-Origin and related security headers
- Named policies: Creating reusable CORS configurations for different scenarios
- Environment-specific setup: Different policies for development and production
- Security considerations: Avoiding overly permissive CORS policies
Learning outcomes:
- Understand browser same-origin policy and CORS
- Configure CORS in ASP.NET Core
- Create named CORS policies for different use cases
- Set appropriate allowed origins, methods, and headers
- Implement environment-specific CORS policies
- Avoid common CORS security pitfalls
32.1 Understanding CORS Fundamentals
CORS = Cross-Origin Resource Sharing
By default, browsers block JavaScript from making requests to different origins. This is the same-origin policy.
Origin = scheme + domain + port
These are different origins:
https://example.com
https://api.example.com ← Different subdomain
https://example.com:8080 ← Different port
http://example.com ← Different scheme
http://example.com:80 ← Still different (http vs https)
The Problem:
Frontend (https://app.example.com) in browser
↓
Makes request to API (https://api.example.com)
↓
Browser blocks it
↓
"No 'Access-Control-Allow-Origin' header"
The Solution: CORS Headers
Your API tells the browser: “Yes, requests from app.example.com are allowed.”
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
...
Browser sees these headers, allows the request. ✓
32.2 Simple CORS Setup
Out of the box, ASP.NET Core blocks all cross-origin requests. Enable CORS:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Add CORS with default policy
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(builder =>
{
builder
.AllowAnyOrigin() // Allow any origin (⚠️ Unsafe in production!)
.AllowAnyMethod() // Allow any HTTP method
.AllowAnyHeader(); // Allow any headers
});
});
var app = builder.Build();
app.UseCors(); // MUST be before MapControllers
app.MapControllers();
app.Run();
Result: Requests from http://localhost:3000, https://example.com, anywhere—all allowed.
⚠️ Security Warning: Never use AllowAnyOrigin() in production. It’s a security hole.
32.3 Named CORS Policies
Restrict which origins can access your API:
// Program.cs
builder.Services.AddCors(options =>
{
// Policy 1: Web application
options.AddPolicy("WebAppPolicy", builder =>
{
builder
.WithOrigins(
"https://app.example.com",
"https://www.example.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials(); // Allow cookies/auth headers
});
// Policy 2: Mobile app (allow localhost for testing)
options.AddPolicy("MobileAppPolicy", builder =>
{
builder
.WithOrigins(
"https://api.mobile.example.com",
"http://localhost:3000") // For dev
.AllowAnyMethod()
.AllowAnyHeader();
});
// Policy 3: Partner integrations (restrictive)
options.AddPolicy("PartnerPolicy", builder =>
{
builder
.WithOrigins("https://partner.example.com")
.WithMethods("GET", "POST") // Only GET and POST
.WithHeaders("Content-Type", "Authorization") // Only these headers
.WithExposedHeaders("X-Total-Count") // Expose custom headers to client
.AllowCredentials();
});
});
var app = builder.Build();
app.UseCors(); // Default policy applied globally
app.MapControllers();
app.Run();
Apply specific policy to controller/action:
// Controllers/CommandsController.cs
using Microsoft.AspNetCore.Cors;
[ApiController]
[Route("api/[controller]")]
public class CommandsController : ControllerBase
{
[HttpGet]
[EnableCors("WebAppPolicy")] // Use WebAppPolicy for this endpoint
public async Task<ActionResult> GetCommands()
{
var commands = await _repository.GetCommandsAsync();
return Ok(commands);
}
[HttpPost]
[EnableCors("PartnerPolicy")] // Partners can only POST
public async Task<ActionResult> CreateCommand(CommandMutateDto dto)
{
var command = _mapper.Map<Command>(dto);
_repository.CreateCommand(command);
await _repository.SaveChangesAsync();
return CreatedAtRoute(nameof(GetCommandById), new { id = command.Id }, command);
}
[HttpDelete("{id}")]
[DisableCors] // Disable CORS for delete—no cross-origin deletes allowed
public async Task<ActionResult> DeleteCommand(int id)
{
var command = await _repository.GetCommandByIdAsync(id);
if (command == null)
return NotFound();
_repository.DeleteCommand(command);
await _repository.SaveChangesAsync();
return NoContent();
}
}
32.4 CORS Preflight Requests
Before sending a complex request, browsers send an OPTIONS preflight first:
Browser's preflight request:
┌─────────────────────────────────────────┐
│ OPTIONS /api/commands HTTP/1.1 │
│ Origin: https://app.example.com │
│ Access-Control-Request-Method: POST │
│ Access-Control-Request-Headers: Content-Type │
└─────────────────────────────────────────┘
↓
API checks policy
↓
┌─────────────────────────────────────────┐
│ HTTP/1.1 200 OK │
│ Access-Control-Allow-Origin: https://app.example.com │
│ Access-Control-Allow-Methods: GET, POST, PUT, DELETE │
│ Access-Control-Allow-Headers: Content-Type │
│ Access-Control-Max-Age: 3600 │
└─────────────────────────────────────────┘
↓
"OK, you can make the real request"
↓
Browser's actual request:
┌─────────────────────────────────────────┐
│ POST /api/commands HTTP/1.1 │
│ Content-Type: application/json │
│ Origin: https://app.example.com │
│ ... │
└─────────────────────────────────────────┘
ASP.NET Core handles this automatically once you configure CORS. You don’t need to code anything special.
Optimization: Set WithOptionalPreflightRequest() to skip preflight for simple requests:
options.AddPolicy("WebAppPolicy", builder =>
{
builder
.WithOrigins("https://app.example.com")
.AllowAnyMethod()
.AllowAnyHeader();
// Cache preflight response for 1 hour
builder.SetPreflightMaxAge(TimeSpan.FromHours(1));
});
32.5 Environment-Specific CORS Configuration
You need different policies for dev, staging, and production:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
var environment = builder.Environment;
if (environment.IsDevelopment())
{
// Development: Allow localhost on any port for testing
options.AddPolicy("DevelopmentPolicy", policy =>
{
policy
.WithOrigins(
"http://localhost:3000",
"http://localhost:4200",
"http://localhost:8080",
"http://127.0.0.1:3000")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromSeconds(600));
});
options.AddDefaultPolicy("DevelopmentPolicy");
}
else if (environment.IsStaging())
{
// Staging: Allow staging apps only
options.AddPolicy("StagingPolicy", policy =>
{
policy
.WithOrigins(
"https://staging-app.example.com",
"https://staging-admin.example.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromHours(1));
});
options.AddDefaultPolicy("StagingPolicy");
}
else
{
// Production: Explicit whitelist only
options.AddPolicy("ProductionPolicy", policy =>
{
policy
.WithOrigins(
"https://app.example.com",
"https://admin.example.com",
"https://www.example.com")
.WithMethods("GET", "POST", "PUT", "DELETE", "PATCH")
.WithHeaders("Content-Type", "Authorization", "X-API-Key")
.WithExposedHeaders("X-Total-Count", "X-Page-Count")
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromHours(24));
});
options.AddDefaultPolicy("ProductionPolicy");
}
});
var app = builder.Build();
app.UseCors();
app.MapControllers();
app.Run();
Even better: Use configuration:
// appsettings.json
{
"CorsSettings": {
"AllowedOrigins": [
"https://app.example.com",
"https://admin.example.com"
],
"AllowedMethods": ["GET", "POST", "PUT", "DELETE"],
"AllowedHeaders": ["Content-Type", "Authorization"],
"AllowCredentials": true,
"PreflightMaxAgeSeconds": 86400
}
}
// appsettings.Development.json
{
"CorsSettings": {
"AllowedOrigins": [
"http://localhost:3000",
"http://localhost:4200"
],
"AllowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
"AllowedHeaders": ["*"],
"AllowCredentials": true,
"PreflightMaxAgeSeconds": 600
}
}
// Program.cs
var corsSettings = builder.Configuration.GetSection("CorsSettings").Get<CorsSettings>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(corsSettings.AllowedOrigins.ToArray())
.WithMethods(corsSettings.AllowedMethods.ToArray())
.WithHeaders(corsSettings.AllowedHeaders.ToArray());
if (corsSettings.AllowCredentials)
policy.AllowCredentials();
policy.SetPreflightMaxAge(
TimeSpan.FromSeconds(corsSettings.PreflightMaxAgeSeconds));
});
});
// Models/CorsSettings.cs
public class CorsSettings
{
public string[] AllowedOrigins { get; set; }
public string[] AllowedMethods { get; set; }
public string[] AllowedHeaders { get; set; }
public bool AllowCredentials { get; set; }
public int PreflightMaxAgeSeconds { get; set; }
}
32.6 CORS Headers Explained
Each CORS header has a purpose:
Access-Control-Allow-Origin
Which origins can access this resource.
Access-Control-Allow-Origin: https://app.example.com
// Only app.example.com can access
Access-Control-Allow-Origin: *
// Any origin (⚠️ Unsafe with credentials)
Access-Control-Allow-Methods
Which HTTP methods are allowed.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers
Which request headers the browser can send.
Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key
Access-Control-Expose-Headers
Which response headers the browser exposes to JavaScript.
// Your API response has custom headers
Response.Headers.Add("X-Total-Count", "100");
Response.Headers.Add("X-Page-Count", "10");
// But browser won't let JavaScript access them unless you expose them
Access-Control-Expose-Headers: X-Total-Count, X-Page-Count
JavaScript can now read these:
fetch('https://api.example.com/api/commands')
.then(response => {
// These are now accessible
const totalCount = response.headers.get('X-Total-Count');
const pageCount = response.headers.get('X-Page-Count');
});
Access-Control-Allow-Credentials
Whether cookies and auth headers are sent with requests.
// Allow the browser to send cookies
builder
.WithOrigins("https://app.example.com")
.AllowCredentials(); // Sets Access-Control-Allow-Credentials: true
JavaScript:
// Include credentials (cookies) in the request
fetch('https://api.example.com/api/commands', {
credentials: 'include' // Include cookies
})
.then(response => response.json());
Access-Control-Max-Age
How long to cache the preflight response (in seconds).
Access-Control-Max-Age: 3600
// Browser won't send preflight again for 1 hour
32.7 CORS with Authentication
Often your API requires auth headers:
// Program.cs
options.AddPolicy("WebAppPolicy", policy =>
{
policy
.WithOrigins("https://app.example.com")
.AllowAnyMethod()
.WithHeaders("Content-Type", "Authorization") // Allow Auth header
.WithExposedHeaders("Authorization") // Expose new tokens if returned
.AllowCredentials(); // Allow sending cookies with auth
});
var app = builder.Build();
app.UseAuthentication(); // MUST be before UseCors
app.UseAuthorization();
app.UseCors();
app.MapControllers();
app.Run();
JavaScript client with auth:
const token = localStorage.getItem('authToken');
fetch('https://api.example.com/api/commands', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` // Auth header
},
credentials: 'include', // Include cookies
body: JSON.stringify({ howTo: '...', commandLine: '...' })
})
.then(response => {
if (response.status === 401) {
// Token expired, refresh and retry
}
return response.json();
});
32.8 CORS Debugging
Check what headers your browser is sending:
Open DevTools → Network tab → Click request → Headers
Look for:
Request Headers:
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
Response Headers:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type
Common CORS errors:
| Error | Cause | Fix |
|---|---|---|
No 'Access-Control-Allow-Origin' header |
Origin not allowed | Add to CORS policy |
The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' |
Using AllowAnyOrigin() with credentials |
Use WithOrigins() and AllowCredentials() |
Method not allowed |
HTTP method not in allow list | Add method to WithMethods() |
Header not allowed |
Custom header not in allow list | Add to WithHeaders() |
Test CORS with curl:
# Simulate browser preflight
curl -X OPTIONS https://api.example.com/api/commands \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
-v
# Look for Access-Control-Allow-* headers in response
32.9 CORS Security Considerations
Mistake 1: Using AllowAnyOrigin() with credentials
// ❌ WRONG - Allows any origin to send credentials
builder
.AllowAnyOrigin()
.AllowCredentials(); // Browsers reject this!
Mistake 2: Hardcoding localhost in production
// ❌ WRONG - Dev config in prod
var isProduction = builder.Environment.IsProduction();
options.AddPolicy("BrokenPolicy", policy =>
{
var origins = isProduction
? new[] { "https://app.example.com" }
: new[] { "http://localhost:3000", "http://localhost:4200" };
policy.WithOrigins(origins); // Still have dev URLs if logic breaks
});
Mistake 3: Exposing sensitive headers
// ❌ WRONG - Exposing internal headers
policy.WithExposedHeaders("*"); // Don't do this
// ✓ RIGHT - Only expose what clients need
policy.WithExposedHeaders("X-Total-Count", "X-Page-Count");
Best Practices:
- Whitelist origins: Never use
AllowAnyOrigin()in production - Use environment-specific configs: Different policies for dev/staging/prod
- Restrict methods: Only allow GET, POST, PUT, DELETE—not OPTIONS
- Be specific with headers: List exactly which headers you allow
- Use HTTPS: CORS is extra layer, not replacement for HTTPS
- Rotate origins: If frontend domain changes, update CORS immediately
- Monitor CORS requests: Log when origins request access
32.10 CORS with IIS (Production Deployment)
When deploying to IIS, ensure CORS middleware runs:
// Program.cs
var app = builder.Build();
// CORS must be before authentication, authorization, and controllers
app.UseCors();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
In IIS, don’t configure CORS in web.config—let ASP.NET Core handle it. The middleware runs on every request.
32.11 Testing CORS with Postman
Postman doesn’t enforce browser CORS rules, but you can test your API’s CORS headers:
- Create a request to your endpoint
- Send it
- Look at response headers
- Verify
Access-Control-Allow-Originand other CORS headers are present - Try from a different origin (simulated in browser context)
Better: Use real browser testing:
<!-- test-cors.html in browser -->
<!DOCTYPE html>
<html>
<body>
<button onclick="testCors()">Test CORS</button>
<pre id="result"></pre>
<script>
async function testCors() {
try {
const response = await fetch('https://api.example.com/api/commands', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
document.getElementById('result').textContent =
`Status: ${response.status}\nData: ${JSON.stringify(data, null, 2)}`;
} catch (error) {
document.getElementById('result').textContent =
`Error: ${error.message}`;
}
}
</script>
</body>
</html>
32.12 What’s Next
You now have:
- ✓ Understanding of CORS fundamentals and same-origin policy
- ✓ Simple and named CORS policies
- ✓ Environment-specific configuration
- ✓ Proper security practices
- ✓ Debugging techniques
- ✓ Authentication with CORS
Next: Compression & Performance—reduce response sizes with Gzip and Brotli compression.