CORS Production Debugging

Introduction

Welcome to our lesson on Production Considerations & Debugging for CORS in ASP.NET Core REST APIs. As you prepare your application for production deployment, understanding how to configure CORS differently across environments becomes critical. What works great in development—like allowing all origins—can create serious security vulnerabilities in production.

In this lesson, we'll explore how to implement environment-specific CORS configurations that maintain security without hindering development productivity. We'll also dive into effective debugging techniques to help you quickly identify and resolve CORS issues before they impact your users. By the end, you'll have practical strategies for managing CORS across your application's entire lifecycle.

Let's get started 🚀

Environment-Specific Configurations

In professional web development, your application lives in multiple environments throughout its lifecycle. Each environment serves a different purpose and requires tailored security configurations.

The development environment is your local workspace where rapid iteration is key. Here, CORS policies need to be lenient enough to support multiple local origins running on different ports. You'll want verbose logging to understand exactly what's happening with each request. Security can be relaxed because only developers access this environment, and the focus is on productivity rather than protection.

The production environment is where your real users interact with your application. This environment demands strict security measures with zero tolerance for misconfiguration. Only specific, verified domains should be allowed, and debugging information must be minimal to prevent leaking implementation details to potential attackers. Every CORS decision here directly impacts your application's security posture.

Between these two extremes, you often have staging or testing environments that mimic production but allow additional flexibility for quality assurance. These environments serve as your final security checkpoint, letting you verify CORS behavior under production-like conditions before actual deployment.

This separation isn't just about security—it's about maintaining developer productivity while ensuring production safety. The key is making these distinctions explicit in your configuration rather than relying on code changes or manual switches during deployment.

Benefits And Use Cases

Environment-specific CORS configurations provide several advantages that become increasingly important as your application scales. First, you gain better security in production by limiting access to only verified domains while maintaining development flexibility by allowing broader access locally. This dual approach means developers can work efficiently without compromising production security.

The configuration-based approach also reduces deployment risks. Rather than changing code between environments, you're simply activating different configuration files, which is safer and more auditable. Your CORS policies become part of your infrastructure as code, making them versionable and reviewable.

However, this approach does add complexity. You now have multiple configuration files to maintain and must ensure consistency across environments where appropriate. There's also the risk of deployment mistakes if the wrong configuration is activated in the wrong environment.

This pattern is essential for multi-tier applications where frontend and backend are deployed separately, especially when frontend developers need to test against local backend instances. It's equally important for public APIs consumed by multiple client applications across different domains. Whenever your local development setup differs from production—which is nearly always—environment-specific CORS becomes a necessity rather than a luxury.

Defining Configuration Settings

Let's start by creating a strongly-typed class to represent our CORS settings. This approach leverages ASP.NET Core's configuration system while providing compile-time type safety and IntelliSense support.

C#
public class CorsSettings
{
    public string[] AllowedOrigins { get; set; } = Array.Empty<string>();
    public bool AllowCredentials { get; set; }
    public bool EnableDebugLogging { get; set; }
}

This class serves as a contract for our CORS configuration. The AllowedOrigins array holds the domains permitted to access our API, initialized to an empty array for safety. The AllowCredentials flag determines whether cookies and authentication headers can be included in cross-origin requests. Finally, EnableDebugLogging controls whether we output verbose CORS-related logs, which we'll want in development but not in production.

Now we'll create environment-specific configuration files. ASP.NET Core uses a layered configuration system: when you call WebApplication.CreateBuilder(args), the framework automatically loads appsettings.json first, then overlays appsettings.{Environment}.json based on the ASPNETCORE_ENVIRONMENT environment variable (which defaults to "Production" if not set). Both files live in your project root alongside Program.cs. Settings in the environment-specific file override matching keys from the base file, so you only need to include the values that differ per environment.

Create or update your appsettings.Development.json:

JSON
{
  "CorsSettings": {
    "AllowedOrigins": [
      "http://localhost:3000",
      "http://localhost:4200"
    ],
    "AllowCredentials": true,
    "EnableDebugLogging": true
  }
}

For development, we're allowing common frontend development ports (React's default 3000 and Angular's 4200). Debug logging is enabled so we can see exactly what's happening with each CORS request.

Next, create your appsettings.Production.json with much stricter settings:

JSON
{
  "CorsSettings": {
    "AllowedOrigins": [
      "https://example.com",
      "https://www.example.com"
    ],
    "AllowCredentials": true,
    "EnableDebugLogging": false
  }
}

Production allows only your official domains over HTTPS, and debug logging is disabled to avoid exposing internal details. Notice we're supporting both the root domain and the www subdomain, which is common for production websites.

Optionally, create an appsettings.Staging.json that mirrors production but with staging-specific domains:

JSON
{
  "CorsSettings": {
    "AllowedOrigins": [
      "https://staging.example.com",
      "https://staging-admin.example.com"
    ],
    "AllowCredentials": true,
    "EnableDebugLogging": true
  }
}

Staging enables debug logging to help identify issues before production deployment while maintaining production-like security constraints.

Configuring CORS Middleware

With our configuration files in place, we can now set up CORS in the application. Open your Program.cs file and start by binding the configuration to our strongly-typed class:

C#
var builder = WebApplication.CreateBuilder(args);

// Bind CORS settings from configuration
var corsSettings = builder.Configuration
    .GetSection("CorsSettings")
    .Get<CorsSettings>() ?? new CorsSettings();

The GetSection method locates the CorsSettings section in the active configuration file, and Get<CorsSettings>() deserializes it into our class. The null-coalescing operator provides a safe fallback to an empty configuration if the section is missing.

Note that we're using corsSettings as a local variable here because we only need it during application startup to configure the CORS policy. If you needed to access these settings later in middleware or services via dependency injection, you would register them with builder.Services.AddSingleton(corsSettings) or use the Options pattern with builder.Services.Configure<CorsSettings>(builder.Configuration.GetSection("CorsSettings")). For our purposes, the local variable approach keeps things simple.

Now we'll register CORS services with a named policy that uses our configuration:

C#
builder.Services.AddCors(options =>
{
    options.AddPolicy("ApiCorsPolicy", policyBuilder =>
    {
        policyBuilder
            .WithOrigins(corsSettings.AllowedOrigins)
            .AllowAnyMethod()
            .AllowAnyHeader();

        if (corsSettings.AllowCredentials)
        {
            policyBuilder.AllowCredentials();
        }
    });
});

This configuration creates a named policy called ApiCorsPolicy using the fluent API. The WithOrigins method takes our configured allowed origins array. We're permitting any HTTP method (GET, POST, PUT, DELETE, etc.) and any request headers for maximum flexibility. Credentials support is added conditionally based on our configuration flag—this is important because AllowCredentials() cannot be combined with wildcard origins for security reasons.

After building the application, we apply the CORS policy to the middleware pipeline and define our endpoints:

C#
var app = builder.Build();

app.UseCors("ApiCorsPolicy");

app.MapGet("/api/test/cors-test", () => 
    Results.Ok(new { message = "CORS is working!", timestamp = DateTime.UtcNow }));

app.Run();

The UseCors middleware is positioned before our endpoint mappings to ensure it processes every API request. The middleware examines incoming requests, checks origins against our policy, and adds appropriate CORS headers to responses.

For scenarios requiring dynamic origin validation beyond simple list matching, you can use SetIsOriginAllowed:

C#
builder.Services.AddCors(options =>
{
    options.AddPolicy("ApiCorsPolicy", policyBuilder =>
    {
        policyBuilder
            .SetIsOriginAllowed(origin =>
            {
                // Allow requests with no origin (tools like Postman)
                if (string.IsNullOrEmpty(origin))
                    return true;

                // Check if origin is in our allowed list
                return corsSettings.AllowedOrigins.Contains(origin);
            })
            .AllowAnyMethod()
            .AllowAnyHeader();

        if (corsSettings.AllowCredentials)
        {
            policyBuilder.AllowCredentials();
        }
    });
});

This approach provides a lambda function that receives each requesting origin and returns true or false. It's particularly useful when you need to allow requests without origin headers (common from API testing tools or mobile apps) while still validating browser-based requests. The function gives you complete control over the validation logic, allowing for complex scenarios like pattern matching or database lookups.

Pattern-Based Origin Validation

While explicitly listing allowed origins works well for small applications, real-world scenarios often require more flexibility. Imagine managing an application with multiple subdomains—api.example.com, cdn.example.com, staging.example.com, dev.example.com, and potentially dozens more. Maintaining an exhaustive list becomes tedious and error-prone. Pattern-based validation solves this by allowing you to define rules that match entire categories of origins while maintaining security boundaries.

Let's enhance our CorsSettings class to support pattern matching using regular expressions:

C#
using System.Text.RegularExpressions;

public class CorsSettings
{
    public string[] AllowedOrigins { get; set; } = Array.Empty<string>();
    public string[] AllowedOriginPatterns { get; set; } = Array.Empty<string>();
    public bool AllowCredentials { get; set; }
    public bool EnableDebugLogging { get; set; }
}

The new AllowedOriginPatterns property holds regex patterns that will be tested against incoming origins. This gives us the power to define flexible rules while keeping the configuration separate from code.

Now update your configuration files to include patterns. In appsettings.Development.json:

JSON
{
  "CorsSettings": {
    "AllowedOrigins": [
      "http://localhost:3000",
      "http://localhost:4200"
    ],
    "AllowedOriginPatterns": [
      "^http://localhost:[0-9]{4,5}$"
    ],
    "AllowCredentials": true,
    "EnableDebugLogging": true
  }
}

The pattern ^http://localhost:[0-9]{4,5}$ matches any localhost origin with a 4 or 5-digit port number, perfect for development where you might spin up multiple frontend instances.

For production, you'll want more restrictive patterns in appsettings.Production.json:

JSON
{
  "CorsSettings": {
    "AllowedOrigins": [
      "https://example.com"
    ],
    "AllowedOriginPatterns": [
      "^https://[\\w-]+\\.example\\.com$"
    ],
    "AllowCredentials": true,
    "EnableDebugLogging": false
  }
}

The pattern ^https://[\\w-]+\\.example\\.com$ matches any HTTPS subdomain of example.com, such as api.example.com, cdn.example.com, or admin.example.com. Note that this pattern intentionally requires at least one subdomain label before .example.com, so it won't match the bare root domain https://example.com. That's by design—the root domain is handled by the exact match in AllowedOrigins. This two-tier approach (exact matches for known domains, patterns for dynamic subdomains) keeps each mechanism focused and predictable. The ^ and $ anchors ensure the entire origin matches, preventing partial matches. The double backslashes in the JSON escape the regex special characters properly.

Optionally, create an appsettings.Staging.json that mirrors production but with staging-specific domains:

JSON
{
  "CorsSettings": {
    "AllowedOrigins": [
      "https://staging.example.com",
      "https://staging-admin.example.com"
    ],
    "AllowedOriginPatterns": [
      "^https://[\\w-]+\\.staging\\.example\\.com$"
    ],
    "AllowCredentials": true,
    "EnableDebugLogging": true
  }
}

Now let's implement the pattern validation logic in Program.cs. We'll compile the regex patterns once at startup for optimal performance:

C#
using System.Text.RegularExpressions;

var builder = WebApplication.CreateBuilder(args);

var corsSettings = builder.Configuration
    .GetSection("CorsSettings")
    .Get<CorsSettings>() ?? new CorsSettings();

// Compile regex patterns once at startup for performance
var compiledPatterns = corsSettings.AllowedOriginPatterns
    .Select(pattern => 
    {
        try
        {
            return new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
        }
        catch (ArgumentException ex)
        {
            Console.WriteLine($"Invalid regex pattern '{pattern}': {ex.Message}");
            return null;
        }
    })
    .Where(regex => regex != null)
    .Cast<Regex>()
    .ToList();

builder.Services.AddCors(options =>
{
    options.AddPolicy("ApiCorsPolicy", policyBuilder =>
    {
        policyBuilder
            .SetIsOriginAllowed(origin =>
            {
                if (string.IsNullOrEmpty(origin))
                    return false;

                // First, check exact matches for fast path
                if (corsSettings.AllowedOrigins.Contains(origin, StringComparer.OrdinalIgnoreCase))
                    return true;

                // Then check against compiled patterns
                return compiledPatterns.Any(pattern => pattern.IsMatch(origin));
            })
            .AllowAnyMethod()
            .AllowAnyHeader();

        if (corsSettings.AllowCredentials)
        {
            policyBuilder.AllowCredentials();
        }
    });
});

This implementation follows a two-tier validation strategy. First, it checks for exact matches using a simple Contains check—this is fast and handles the common case (including the root domain). If that fails, it tests the origin against each compiled regex pattern to catch dynamic subdomains. The patterns are compiled once at startup with RegexOptions.Compiled, which generates optimized matching code and significantly improves performance for frequently-checked origins.

The error handling ensures that invalid regex patterns don't crash your application. Instead, they're logged and skipped, allowing your application to start even if someone commits a malformed pattern.

Benefits of pattern-based validation:

  • Scalability: Handle unlimited subdomains without configuration bloat
  • Maintainability: Change one pattern instead of updating dozens of individual origins
  • Flexibility: Support dynamic subdomain allocation for multi-tenant applications
  • Security: Patterns still enforce strict boundaries—you control exactly which domains match
  • Performance: Compiled regex patterns execute efficiently, even with multiple patterns

Security considerations: While patterns provide flexibility, they require careful design. Always use anchors (^ and $) to prevent partial matches, and test your patterns thoroughly. A poorly designed pattern like https://.*example.com could inadvertently match https://malicious-example.com, creating a security vulnerability.

This pattern-based approach scales beautifully as your application grows, supporting everything from simple subdomain matching to complex multi-tenant scenarios where each customer gets their own subdomain.

Route-Specific CORS Policies

As we explored in Lessons 1 and 2, ASP.NET Core lets you apply different CORS policies to different route groups using MapGroup() with RequireCors(). In this section, we'll see how that technique combines with environment-specific settings to create policies that vary by both route sensitivity and deployment environment.

Define multiple policies that all draw their origins from the same corsSettings, but differ in what they allow:

C#
builder.Services.AddCors(options =>
{
    options.AddPolicy("PublicPolicy", policy =>
    {
        policy.WithOrigins(corsSettings.AllowedOrigins)
            .AllowAnyMethod()
            .AllowAnyHeader();
    });

    options.AddPolicy("AuthPolicy", policy =>
    {
        policy.WithOrigins(corsSettings.AllowedOrigins)
            .AllowAnyMethod()
            .AllowAnyHeader();
        
        if (corsSettings.AllowCredentials)
            policy.AllowCredentials();
    });

    options.AddPolicy("AdminPolicy", policy =>
    {
        policy.WithOrigins(corsSettings.AllowedOrigins)
            .WithMethods("GET", "POST")
            .AllowAnyHeader();
        
        if (corsSettings.AllowCredentials)
            policy.AllowCredentials();
    });
});

Because the allowed origins come from configuration, switching from development to production automatically tightens every policy—no code changes required. Apply them to route groups just as before:

C#
var app = builder.Build();

app.UseCors();

var publicApi = app.MapGroup("/api/public").RequireCors("PublicPolicy");
publicApi.MapGet("/articles", () => Results.Ok(new[] { "Article 1", "Article 2" }));

var authApi = app.MapGroup("/api/auth").RequireCors("AuthPolicy");
authApi.MapPost("/login", (LoginRequest req) => 
    Results.Ok(new { token = "jwt-token-here" }));

var adminApi = app.MapGroup("/api/admin").RequireCors("AdminPolicy");
adminApi.MapGet("/users", () => Results.Ok(new[] { "User 1", "User 2" }));

app.Run();

The key insight is that environment-specific configuration and route-specific policies are complementary: configuration controls which origins are allowed, while route-level policies control what those origins can do on each endpoint group.

Debugging CORS Issues

CORS problems can be notoriously difficult to diagnose because the browser often provides cryptic error messages. To effectively debug these issues, we'll create custom middleware that logs detailed information about CORS request processing.

Let's start by creating a dedicated middleware class:

C#
public class CorsDebugMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<CorsDebugMiddleware> _logger;
    private readonly CorsSettings _corsSettings;

    public CorsDebugMiddleware(
        RequestDelegate next, 
        ILogger<CorsDebugMiddleware> logger,
        IConfiguration configuration)
    {
        _next = next;
        _logger = logger;
        _corsSettings = configuration
            .GetSection("CorsSettings")
            .Get<CorsSettings>() ?? new CorsSettings();
    }

This middleware follows ASP.NET Core's standard pattern, accepting the next middleware delegate through the constructor along with logger and configuration dependencies. We load the CORS settings to check if debug logging is enabled.

The core logic resides in the InvokeAsync method:

C#
    public async Task InvokeAsync(HttpContext context)
    {
        var origin = context.Request.Headers.Origin.ToString();

        if (!string.IsNullOrEmpty(origin) && _corsSettings.EnableDebugLogging)
        {
            _logger.LogInformation(
                "[CORS Debug] Request from: {Origin}, path: {Path}", 
                origin, 
                context.Request.Path);

We extract the origin header from the request and log it along with the request path. This immediately shows us which domain is attempting access and what resource they're requesting.

The interesting part is capturing response headers using a callback:

C#
            context.Response.OnStarting(() =>
            {
                var corsHeaders = context.Response.Headers
                    .Where(h => h.Key.StartsWith("Access-Control-", 
                        StringComparison.OrdinalIgnoreCase))
                    .ToDictionary(h => h.Key, h => h.Value.ToString());

                _logger.LogInformation(
                    "[CORS Debug] Response headers: {@CorsHeaders}", 
                    corsHeaders);

                return Task.CompletedTask;
            });
        }

        await _next(context);
    }
}

The OnStarting callback executes just before the response is sent to the client, after all other middleware has processed the request. This timing is crucial because it lets us capture the final state of CORS headers that ASP.NET Core added during processing. We filter for headers starting with "Access-Control-" and log them as a structured object, making it easy to see which CORS headers were set and their values.

To use this middleware, register it conditionally in Program.cs:

C#
var app = builder.Build();

if (corsSettings.EnableDebugLogging)
{
    app.UseMiddleware<CorsDebugMiddleware>();
}

app.UseCors("ApiCorsPolicy");

app.MapGet("/api/test/cors-test", () => 
    Results.Ok(new { message = "CORS is working!", timestamp = DateTime.UtcNow }));

app.Run();

The middleware is registered only when EnableDebugLogging is true, ensuring verbose logging appears in development but not in production. It's positioned before UseCors to capture the complete CORS processing lifecycle.

For simpler scenarios, you can use inline middleware:

C#
if (corsSettings.EnableDebugLogging)
{
    app.Use(async (context, next) =>
    {
        var logger = context.RequestServices
            .GetRequiredService<ILogger<Program>>();
        
        var origin = context.Request.Headers.Origin.ToString();

        if (!string.IsNullOrEmpty(origin))
        {
            logger.LogInformation(
                "[CORS Debug] Request from {Origin}, path: {Path}", 
                origin,
                context.Request.Path);
        }

        await next();

        // Log response headers after processing
        foreach (var header in context.Response.Headers
            .Where(h => h.Key.StartsWith("Access-Control-")))
        {
            logger.LogInformation("[CORS Debug] {Header}: {Value}", 
                header.Key, header.Value);
        }
    });
}

This inline approach captures both request origin and response headers, providing comprehensive visibility into CORS processing without requiring a separate middleware class.

Testing CORS Configuration

To verify your CORS configuration works correctly, create a test endpoint and use HttpClient to simulate cross-origin requests:

C#
app.MapGet("/api/test/cors-test", () => 
    Results.Ok(new { message = "CORS is working!", timestamp = DateTime.UtcNow }));

Now create a simple test client to simulate requests from different origins:

C#
public class CorsTestClient
{
    public static async Task TestCorsAsync(string apiUrl, string origin)
    {
        using var client = new HttpClient();
        
        // Add Origin header to simulate cross-origin request
        client.DefaultRequestHeaders.Add("Origin", origin);
        
        try
        {
            var response = await client.GetAsync(apiUrl);
            
            Console.WriteLine($"\nTesting CORS from: {origin}");
            Console.WriteLine($"Status Code: {response.StatusCode}");
            Console.WriteLine("CORS Headers:");
            
            foreach (var header in response.Headers
                .Where(h => h.Key.StartsWith("Access-Control-")))
            {
                Console.WriteLine($"  {header.Key}: {string.Join(", ", header.Value)}");
            }
            
            if (response.IsSuccessStatusCode)
            {
                var content = await response.Content.ReadAsStringAsync();
                Console.WriteLine($"Response: {content}");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

You can test this in your Program.cs or a separate test project:

C#
// Test with Development environment origins
await CorsTestClient.TestCorsAsync(
    "https://localhost:5001/api/test/cors-test", 
    "http://localhost:3000");

// Test with Production environment origins
await CorsTestClient.TestCorsAsync(
    "https://localhost:5001/api/test/cors-test", 
    "https://example.com");

// Test with an unauthorized origin
await CorsTestClient.TestCorsAsync(
    "https://localhost:5001/api/test/cors-test", 
    "https://unauthorized-domain.com");

When testing with an allowed origin (http://localhost:3000 in development), you should see output like:

text
Testing CORS from: http://localhost:3000
Status Code: OK
CORS Headers:
  Access-Control-Allow-Origin: http://localhost:3000
  Access-Control-Allow-Credentials: true
Response: {"message":"CORS is working!","timestamp":"2024-01-15T10:30:00Z"}

The presence of Access-Control-Allow-Origin matching your request origin confirms CORS is properly configured. The Access-Control-Allow-Credentials header appears when credentials are allowed.

When testing with an unauthorized origin, you'll see:

text
Testing CORS from: https://unauthorized-domain.com
Status Code: OK
CORS Headers:
Response: {"message":"CORS is working!","timestamp":"2024-01-15T10:30:00Z"}

Notice the absence of any Access-Control-* headers. The server responds successfully (200 OK) but doesn't include CORS headers, which causes the browser to block the response. This is the expected behavior—the API processes the request, but the browser prevents JavaScript from accessing the response due to missing CORS headers.

This testing approach helps you verify configuration correctness in each environment before deploying, catching issues early when they're easiest to fix.

Conclusion

In this lesson, we've covered the essential practices for managing CORS in production ASP.NET Core applications. We explored how to implement environment-specific configurations using strongly-typed classes and JSON configuration files, ensuring your development environment remains flexible while production stays secure.

The debugging techniques we covered—particularly custom middleware and structured logging—provide visibility into CORS request processing that's crucial when troubleshooting issues. By conditionally enabling verbose logging through configuration, you maintain security in production while having powerful diagnostic tools in development.

We also demonstrated practical testing strategies using C# and HttpClient to verify CORS behavior across different environments. This testing approach helps you catch configuration issues before deployment, reducing the risk of production incidents.

Remember that CORS configuration is not a one-time setup but an ongoing consideration as your application evolves. As you add new frontend clients or modify your deployment architecture, revisit these configurations to ensure they remain appropriate for each environment. The patterns you've learned here provide a solid foundation for maintaining secure, well-configured CORS policies throughout your application's lifecycle.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal