32. CORS Configuration

Understanding and implementing Cross-Origin Resource Sharing with security policies, named policies, and environment-specific 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:

  1. Whitelist origins: Never use AllowAnyOrigin() in production
  2. Use environment-specific configs: Different policies for dev/staging/prod
  3. Restrict methods: Only allow GET, POST, PUT, DELETE—not OPTIONS
  4. Be specific with headers: List exactly which headers you allow
  5. Use HTTPS: CORS is extra layer, not replacement for HTTPS
  6. Rotate origins: If frontend domain changes, update CORS immediately
  7. 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:

  1. Create a request to your endpoint
  2. Send it
  3. Look at response headers
  4. Verify Access-Control-Allow-Origin and other CORS headers are present
  5. 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.