CORS with Credentials

Introduction

Welcome to the third lesson of the "Securing your REST API application with ASP.NET Core" course! In our previous lessons, we explored the basics of cross-origin resource sharing (CORS) and how to handle preflight requests. Now, we're going to tackle one of the most challenging aspects of modern web development: enabling and customizing CORS with credentials for cross-origin cookie authentication.

This lesson addresses a common real-world scenario where your frontend application runs on one domain (perhaps app.example.com or even a CDN) while your API runs on another (api.example.com). You'll learn how to securely share cookies across these different origins while maintaining strong security posture. This capability is essential for maintaining session state and authenticating users across distributed applications.

By the end of this lesson, you'll understand not just how to implement cross-origin cookie authentication, but also when it's appropriate to use this approach and what security considerations you must address. We'll work through practical code examples that you can immediately apply to your projects. Let's dive in! 🌟

Cookies And Web Authentication

Before we tackle the complexities of cross-origin scenarios, let's establish a solid foundation. Cookies are fundamental to how web applications maintain session state — they're small pieces of data that servers send to browsers, which browsers then store and send back with subsequent requests.

Think of cookies as a claim ticket at a coat check. When you log in (check your coat), the server gives you a unique identifier (the ticket). Every time you make a request (want your coat back), you present this ticket, and the server knows who you are without requiring you to log in again. This mechanism allows users to navigate through your application while remaining authenticated.

In cross-origin scenarios, however, browsers enforce strict security policies about cookie sharing. By default, browsers won't send cookies when making requests to a different origin (domain, port, or protocol). This restriction exists for good reason — imagine if any website could make requests to your banking application using your cookies! Understanding these security boundaries is crucial before we learn how to carefully and safely expand them for legitimate use cases.

Cross-Origin Cookie Authentication

Cross-origin cookie authentication involves using cookies for authentication when your frontend and backend exist on different origins. While this sounds straightforward, browsers block this by default due to serious security concerns.

Consider these security implications that browsers protect against:

  • Cross-site request forgery (CSRF) represents a particularly dangerous threat. Without proper restrictions, a malicious website could make authenticated requests to your API using a user's existing cookies, potentially performing unauthorized actions like transferring funds or changing account settings — all without the user's knowledge.

  • Information leakage poses another risk. Cookies often contain sensitive session data or identifiers that shouldn't be accessible to third-party sites. If every website could read cookies from every other domain, user privacy would be completely compromised.

  • Privacy implications extend beyond security. User tracking across multiple domains could violate privacy expectations and regulations. When we enable cross-origin cookie sharing, we must do so carefully and only with trusted domains.

To address these concerns while enabling legitimate cross-origin scenarios, we need a coordinated approach between server and client. The server must explicitly permit specific origins to receive credentials — via AllowCredentials() in the CORS policy — and the client must explicitly request to include credentials with each request. This mutual agreement ensures that cookie sharing only happens when both parties intend it. Additionally, we must configure cookies themselves with proper security attributes that protect against various attack vectors.

Benefits And Trade-Offs

Understanding when to enable CORS with credentials requires carefully evaluating whether the benefits justify the additional complexity and security considerations for your specific use case.

The Benefits:

Session continuity becomes seamless when properly implemented. Users can navigate between related applications or services without repeatedly authenticating. For example, a user logged into your main application at app.example.com can seamlessly access resources from your API at api.example.com without seeing login prompts.

Centralized authentication enables elegant architectures where a dedicated authentication service manages sessions for multiple applications. This approach simplifies identity management across your entire ecosystem and provides a single source of truth for user authentication state.

Familiar patterns mean your team can leverage existing knowledge of cookie-based authentication rather than learning entirely new paradigms. Most developers understand cookie-based sessions, and many existing systems already use this approach.

Real-World Use Cases:

Microservice architectures often require shared authentication across services running on different domains. For instance, your user service, payment service, and notification service might all need to validate the same user session.

Single sign-on (SSO) systems are perhaps the most common use case. When a user logs into one application in your organization's suite, they expect to be authenticated across all related applications without multiple login prompts.

API gateways frequently need to validate authentication before routing requests to multiple backend services. A central gateway can check session validity once rather than each service implementing its own authentication logic.

Frontend-backend separation has become standard practice, with frontend applications deployed on CDNs for performance while backend APIs run on traditional servers. For example, a React application on Netlify communicating with an ASP.NET Core API on Azure App Service requires cross-origin cookie authentication to maintain user sessions.

The Trade-Offs:

Increased attack surface is the primary concern. Enabling cross-origin cookies creates additional security considerations that must be carefully addressed through proper configuration and ongoing vigilance.

Configuration complexity can lead to subtle vulnerabilities if not done correctly. Precise setup of both CORS policies and cookie settings is essential — a single misconfigured attribute can either break functionality or create security holes.

Browser compatibility varies, as different browsers implement security features differently. While modern browsers generally support the necessary features, you may encounter edge cases that require testing and potentially alternative approaches.

Debugging challenges arise because cross-origin cookie issues can manifest in ways that aren't immediately obvious. Browser developer tools and network inspectors become essential for troubleshooting these scenarios.

Enabling CORS With Credentials

Now let's implement secure cross-origin cookie sharing in our ASP.NET Core application using Minimal APIs. The key is enabling CORS with credentials while specifying exactly which origins can access our resources.

First, let's configure the CORS policy in our application setup:

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

// Add CORS services with credentials enabled
builder.Services.AddCors(options =>
{
    options.AddPolicy("CredentialsPolicy", policy =>
    {
        policy.WithOrigins("http://localhost:3000")  // Specific origin, not *
              .AllowCredentials()                     // Allow cookies to be sent
              .WithMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
              .WithHeaders("Content-Type", "Authorization");
    });
});

This configuration does several critical things. The WithOrigins() method specifies exactly which domain can access our resources — we must use a specific origin because wildcard * is incompatible with credentials for security reasons. The AllowCredentials() method is the heart of this configuration, instructing the server to include the Access-Control-Allow-Credentials: true header in responses.

However, this server-side configuration alone isn't sufficient — both sides must opt in for cross-origin cookie sharing to work. While the server uses AllowCredentials(), the client must also explicitly request to include credentials. In browser fetch() calls, this means setting credentials: 'include':

JavaScript
fetch('https://api.example.com/api/auth/login', {
    method: 'POST',
    credentials: 'include',  // Required: tells the browser to send and store cookies
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username: 'user', password: 'password' })
});

Without credentials: 'include', the browser will not send cookies with the request or store cookies from the response, even if the server has AllowCredentials() configured. This mutual opt-in is by design — it ensures cross-origin cookie sharing only happens when both parties explicitly intend it.

Next, we apply this policy globally and set up route groups for organized endpoint management:

C#
var app = builder.Build();

// Apply the CORS policy before routing
app.UseCors("CredentialsPolicy");

// Create route group for auth endpoints with explicit CORS
var auth = app.MapGroup("/api/auth").RequireCors("CredentialsPolicy");

app.Run();

The order matters here — we call UseCors() before mapping endpoints to ensure the CORS middleware processes requests before they reach our handlers. Using MapGroup() with RequireCors() allows us to apply CORS policies to specific route groups, providing fine-grained control over which endpoints accept cross-origin requests with credentials.

Creating Session Cookies

When a user successfully authenticates, we need to create a session cookie that will be sent with subsequent requests. Let's build a realistic login endpoint using Minimal APIs that demonstrates proper cookie creation with all necessary security attributes.

First, let's define our data structures and session storage:

C#
using System.Collections.Concurrent;

// Data structures for authentication
public class LoginRequest
{
    public string Username { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
}

public class SessionData
{
    public string Username { get; set; } = string.Empty;
    public DateTime LoginTime { get; set; }
}

// In production, use a distributed cache like Redis
var activeSessions = new ConcurrentDictionary<string, SessionData>();

These simple classes represent our login request and session data. We use ConcurrentDictionary rather than a regular Dictionary because ASP.NET Core processes HTTP requests concurrently across multiple threads — simultaneous reads and writes from different requests against a standard Dictionary could cause race conditions or data corruption. ConcurrentDictionary provides built-in thread-safe operations designed for exactly these concurrent access patterns. In a production application, SessionData would likely include additional information like user roles, permissions, and session metadata.

Now let's implement the authentication endpoints using Minimal APIs:

C#
var auth = app.MapGroup("/api/auth").RequireCors("CredentialsPolicy");

auth.MapPost("/login", (LoginRequest request, HttpResponse response) =>
{
    // In a real app, validate credentials against a database
    if (request.Username == "user" && request.Password == "password")
    {
        // Generate unique session ID
        var sessionId = Guid.NewGuid().ToString();
        
        // Store session info
        activeSessions[sessionId] = new SessionData
        {
            Username = request.Username,
            LoginTime = DateTime.UtcNow
        };
        
        // Set session cookie with security settings
        response.Cookies.Append("sessionId", sessionId, new CookieOptions
        {
            HttpOnly = true,                  // Prevents JavaScript access
            MaxAge = TimeSpan.FromHours(1),   // Expires in 1 hour
            SameSite = SameSiteMode.None,     // Required for cross-origin
            Secure = true,                    // Only sent over HTTPS
            Path = "/"                        // Available across entire domain
        });
        
        return Results.Ok(new 
        { 
            success = true, 
            message = "Login successful"
        });
    }
    
    return Results.Json(
        new { success = false, message = "Invalid credentials" },
        statusCode: 401
    );
});

Each cookie attribute serves a specific security purpose. HttpOnly = true prevents client-side JavaScript from accessing the cookie, protecting against XSS attacks. Even if an attacker injects malicious JavaScript into your page, they can't steal the session cookie. The MaxAge setting ensures sessions don't persist indefinitely — expired sessions reduce the window for attack if a session ID is somehow compromised.

The SameSite = SameSiteMode.None attribute is essential for cross-origin scenarios. It explicitly tells the browser that this cookie should be sent with cross-site requests, overriding the default same-site restriction. However, when using SameSite=None, we must also set Secure = true, which ensures the cookie only travels over HTTPS connections, preventing interception. Finally, Path = "/" makes the cookie available across your entire domain rather than just specific paths.

Let's also add logout and session check endpoints:

C#
auth.MapPost("/logout", (HttpRequest request, HttpResponse response) =>
{
    if (request.Cookies.TryGetValue("sessionId", out var sessionId))
    {
        // Remove session from storage
        activeSessions.TryRemove(sessionId, out _);
        
        // Clear the cookie
        response.Cookies.Delete("sessionId", new CookieOptions
        {
            SameSite = SameSiteMode.None,
            Secure = true
        });
    }
    
    return Results.Ok(new { success = true, message = "Logged out successfully" });
});

auth.MapGet("/check-session", (HttpRequest request) =>
{
    if (!request.Cookies.TryGetValue("sessionId", out var sessionId) ||
        !activeSessions.TryGetValue(sessionId, out var sessionData))
    {
        return Results.Json(
            new { authenticated = false },
            statusCode: 401
        );
    }
    
    return Results.Ok(new 
    { 
        authenticated = true, 
        username = sessionData.Username 
    });
});

Validating Session Cookies

Once we've established a session, we need to validate it on protected endpoints. Let's create endpoints that check for valid session cookies and demonstrate the complete request-response flow using Minimal APIs:

C#
app.MapGet("/api/protected-resource", (HttpRequest request) =>
{
    // Attempt to retrieve the session cookie
    if (!request.Cookies.TryGetValue("sessionId", out var sessionId) || 
        string.IsNullOrEmpty(sessionId))
    {
        return Results.Json(
            new { success = false, message = "No session found - please login" },
            statusCode: 401
        );
    }
    
    // Validate the session exists and is active
    if (!activeSessions.TryGetValue(sessionId, out var sessionData))
    {
        return Results.Json(
            new { success = false, message = "Invalid or expired session - please login" },
            statusCode: 401
        );
    }
    
    // Return protected data with session info
    return Results.Ok(new
    {
        success = true,
        message = $"Welcome back, {sessionData.Username}!",
        data = new
        {
            resourceData = "This is protected information",
            sessionInfo = new
            {
                loginTime = sessionData.LoginTime,
                sessionDuration = DateTime.UtcNow - sessionData.LoginTime
            }
        }
    });
}).RequireCors("CredentialsPolicy");

The validation process starts by attempting to retrieve the session cookie from the incoming request. ASP.NET Core's HttpRequest.Cookies collection provides convenient access to all cookies sent by the client. If the cookie doesn't exist or is empty, we immediately return an unauthorized response.

If the cookie exists, we validate that it corresponds to an active session in our store. This two-step verification (cookie exists and session is valid) provides defense in depth — even if an attacker somehow obtains a session ID, it must correspond to an active session to grant access.

Testing With HttpClient And CookieContainer:

To test the complete cookie authentication flow programmatically, you can use HttpClient with a CookieContainer. The CookieContainer automatically stores cookies from responses and includes them in subsequent requests, simulating how a browser handles cookies:

C#
// Configure HttpClient to handle cookies automatically
var handler = new HttpClientHandler
{
    CookieContainer = new CookieContainer(),
    UseCookies = true
};
var client = new HttpClient(handler)
{
    BaseAddress = new Uri("https://localhost:5001")
};

// Step 1: Login — the server sets a session cookie in the response
Console.WriteLine("=== Step 1: Attempting Login ===");
var loginContent = new StringContent(
    """{"username": "user", "password": "password"}""",
    System.Text.Encoding.UTF8,
    "application/json"
);
var loginResponse = await client.PostAsync("/api/auth/login", loginContent);
var loginResult = await loginResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Login Status: {loginResponse.StatusCode}");
Console.WriteLine($"Response: {loginResult}");

// Step 2: Verify the cookie was stored by the CookieContainer
Console.WriteLine("\n=== Step 2: Checking Session Cookie ===");
var cookies = handler.CookieContainer.GetCookies(new Uri("https://localhost:5001"));
var sessionCookie = cookies["sessionId"];
Console.WriteLine($"Session Cookie Set: {sessionCookie != null}");

// Step 3: Access a protected resource — the CookieContainer
// automatically includes the session cookie
Console.WriteLine("\n=== Step 3: Accessing Protected Resource ===");
var resourceResponse = await client.GetAsync("/api/protected-resource");
var resourceResult = await resourceResponse.Content.ReadAsStringAsync();
Console.WriteLine($"Resource Status: {resourceResponse.StatusCode}");
Console.WriteLine($"Response: {resourceResult}");

Sample Output:

text
=== Step 1: Attempting Login ===
Login Status: OK
Response: {"success":true,"message":"Login successful"}

=== Step 2: Checking Session Cookie ===
Session Cookie Set: True

=== Step 3: Accessing Protected Resource ===
Resource Status: OK
Response: {
  "success": true,
  "message": "Welcome back, user!",
  "data": {
    "resourceData": "This is protected information",
    "sessionInfo": {
      "loginTime": "2024-01-15T10:30:00Z",
      "sessionDuration": "00:00:05"
    }
  }
}

The key insight here is that CookieContainer handles cookie storage and transmission automatically — after the login response sets the sessionId cookie, every subsequent request through the same HttpClient instance includes it. This mirrors browser behavior and is the same pattern you'll use when testing your own cookie-based authentication in the upcoming practice exercises.

Security Best Practices

Implementing cross-origin cookie authentication securely requires adherence to several critical best practices that protect against common vulnerabilities.

  • Origin Whitelisting should be strict and explicit. Never use wildcard * origins when credentials are enabled — this configuration is not only insecure but also invalid according to CORS specifications. Maintain a carefully curated list of allowed origins, preferably loaded from configuration rather than hardcoded. Consider using environment-specific origin lists so development, staging, and production environments only allow their respective domains.

  • Cookie Security Attributes must all be properly configured. The combination of HttpOnly, Secure, and SameSite=None provides layered defense against different attack vectors. Never compromise on these attributes for convenience — they exist to protect your users. Additionally, implement appropriate cookie expiration strategies. Short-lived session cookies with reasonable MaxAge values reduce the window of opportunity for attackers.

  • Session Management deserves careful attention. Implement proper session expiration and cleanup to prevent indefinitely valid sessions from accumulating. Consider implementing session renewal for active users while enforcing absolute session timeouts. Store session data securely — use distributed caches like Redis in production rather than in-memory stores, which don't scale and lose data on application restarts.

  • CSRF Protection remains essential even with CORS properly configured. While CORS provides some protection, implementing additional CSRF tokens adds defense in depth. ASP.NET Core provides built-in anti-forgery tokens that should be used for state-changing operations.

  • HTTPS Enforcement is non-negotiable when using SameSite=None. Modern browsers require the Secure attribute, which means cookies only travel over HTTPS. Configure your hosting environment to redirect all HTTP traffic to HTTPS and use HTTP Strict Transport Security (HSTS) headers to enforce this at the browser level.

Alternative Authentication Methods should be considered for scenarios where cross-origin cookie authentication introduces too much complexity or risk. Token-based authentication using JWT (JSON Web Tokens) provides an alternative that avoids some cookie-related security concerns. Here's how you might implement it with Minimal APIs:

C#
app.MapPost("/api/auth/token-login", (LoginRequest request) =>
{
    if (request.Username == "user" && request.Password == "password")
    {
        var token = GenerateJwt(request.Username); // Your JWT generation logic
        return Results.Ok(new { success = true, token });
    }
    return Results.Json(
        new { success = false, message = "Invalid credentials" },
        statusCode: 401
    );
}).AllowAnonymous();

app.MapGet("/api/token-protected", (ClaimsPrincipal user) =>
{
    var username = user.FindFirst(ClaimTypes.Name)?.Value;
    return Results.Ok(new 
    { 
        success = true, 
        message = $"Hello, {username}!" 
    });
}).RequireAuthorization().RequireCors("CredentialsPolicy");

OAuth 2.0 flows offer standardized approaches for many authentication scenarios, particularly when integrating with external identity providers.

Conclusion

In this lesson, we've explored the intricacies of enabling and customizing CORS with credentials for secure cross-origin cookie authentication. You've learned how to properly configure CORS policies that allow credential sharing while maintaining security, how to create session cookies with appropriate security attributes, and how to validate these sessions in protected endpoints.

The key takeaway is that cross-origin cookie authentication requires careful, deliberate configuration at every level — from CORS policies to cookie attributes to session management. While this approach adds complexity compared to same-origin scenarios, it enables powerful architectural patterns like microservices and frontend-backend separation that are fundamental to modern web development.

As you move forward, practice implementing these concepts in your projects while remaining mindful of the security implications. Test your implementation thoroughly, paying special attention to error cases and edge conditions. In the next lesson, we'll continue building on these security foundations to further protect your ASP.NET Core REST API. Keep up the excellent work! 🚀

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