Cookie Authentication

Use cookie sessions for browser clients and BFFs, set SameSite and Secure correctly, and get 401/403 instead of login redirects on API endpoints.

Syntaxbuilder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(o => { o.Cookie.Name = "__Host-shop"; o.Cookie.SecurePolicy = CookieSecurePolicy.Always; }); await context.SignInAsync(principal);

For browser clients a cookie is still the safest session carrier: HttpOnly keeps it away from JavaScript, so an XSS bug cannot read it, which is not true of a token kept in localStorage. AddCookie() serialises the ClaimsPrincipal into an authentication ticket, encrypts and signs it with Data Protection, and writes it as a cookie on SignInAsync. Every request decrypts it; no server-side session store is involved unless you configure one.

Defaults and what to change

SettingDefaultFor an API or BFF
Cookie.Name.AspNetCore.Cookies__Host- prefix: forces Secure, path /, no Domain
Cookie.HttpOnlytrueKeep
Cookie.SecurePolicySameAsRequestAlways; TLS usually ends at the proxy
Cookie.SameSiteLaxLax with OIDC sign-in, Strict otherwise
ExpireTimeSpan14 days, slidingHours, sometimes with an absolute cap

401 and 403 instead of redirects (.NET 10)

The cookie handler's challenge redirects to LoginPath (/Account/Login) and forbid redirects to /Account/AccessDenied. That is right for pages and wrong for fetch, which follows the 302 and gets HTML with status 200. In .NET 10 the handler returns 401 and 403 for endpoints that carry API metadata: [ApiController] controllers and minimal API endpoints that return JSON objects or TypedResults. Endpoints returning a plain string or an untyped Results.Ok(...) are not marked, so they still redirect. Mark a group explicitly with .DisableCookieRedirect(), or restore the redirect for one endpoint with .AllowCookieRedirect(). Before .NET 10, teams overrode Events.OnRedirectToLogin to set 401, and that code is still valid.

BFF and CSRF

In the backend-for-frontend pattern the SPA talks only to its own origin; the BFF holds the session cookie and keeps provider tokens server-side. Cookies are sent automatically, so cookie APIs need CSRF protection: SameSite=Lax blocks cross-site POSTs but not requests from a sibling subdomain, which counts as same-site. Require a custom header such as X-CSRF: 1 on state-changing calls (a cross-origin page cannot send it without passing CORS), or use antiforgery tokens.

Production failure modes

  • Logged out after every deploy, or on every other request: Data Protection keys are per instance by default. Persist the key ring to shared storage for all replicas.
  • 431 or 400 from the proxy: a ticket with many claims exceeds header limits; the handler chunks cookies over about 4 KB, but put fewer claims in, or use SessionStore (ITicketStore) to keep the ticket server-side.
  • Sign-out does not revoke: a copied cookie stays valid until it expires. Check a session version in Events.OnValidatePrincipal and call RejectPrincipal().

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Keep a React shop's session in an HttpOnly __Host- cookie behind a BFF so an XSS bug cannot steal a bearer token.
  • Make fetch('/api/orders') receive 401 instead of a 302 to /Account/Login followed by an HTML page with status 200.
  • Sign a customer out of every device after a password change by bumping a session version checked in OnValidatePrincipal.

More examples

BFF session hardening

<code>OnValidatePrincipal</code> runs on every request carrying the cookie, so cache the version lookup (a few seconds in memory, or Redis) instead of hitting the database each time. Bumping the version on password change or "sign out everywhere" invalidates every copy of the cookie. The <code>X-CSRF</code> filter is the cheapest CSRF defence for a JSON BFF: a form post from another site cannot add custom headers, and a script on another origin cannot either without a CORS preflight you never approve.

Example · csharp
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "__Host-shop";
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Strict;
        options.Cookie.HttpOnly = true;
        options.ExpireTimeSpan = TimeSpan.FromHours(8);
        options.SlidingExpiration = true;

        options.Events.OnValidatePrincipal = async context =>
        {
            // Revocation: compare the session version in the cookie with the current one.
            var sessions = context.HttpContext.RequestServices.GetRequiredService<ISessionVersions>();
            var userId = context.Principal?.FindFirst("sub")?.Value;
            var version = context.Principal?.FindFirst("session_version")?.Value;
            if (userId is null || version != await sessions.CurrentAsync(userId, context.HttpContext.RequestAborted))
            {
                context.RejectPrincipal();
                await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            }
        };
    });
builder.Services.AddSingleton<ISessionVersions, InMemorySessionVersions>();
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

// State-changing BFF calls must carry a header a cross-site form cannot send.
var api = app.MapGroup("/bff").RequireAuthorization().DisableCookieRedirect();
api.AddEndpointFilter(async (context, next) =>
{
    var request = context.HttpContext.Request;
    if (!HttpMethods.IsGet(request.Method) && request.Headers["X-CSRF"] != "1")
        return TypedResults.Problem("Missing X-CSRF header.", statusCode: StatusCodes.Status400BadRequest);
    return await next(context);
});
api.MapPost("/cart/items", (CartItem item) => TypedResults.Ok(item));

app.Run();

record CartItem(string Sku, int Quantity);

interface ISessionVersions
{
    Task<string?> CurrentAsync(string userId, CancellationToken cancellationToken);
}

sealed class InMemorySessionVersions : ISessionVersions
{
    public Task<string?> CurrentAsync(string userId, CancellationToken cancellationToken) => Task.FromResult<string?>("1");
}

Sharing Data Protection keys across replicas

Without this, each pod generates its own keys in memory or in the container's home directory, which disappears on restart. The symptom is users signed out after every deploy, or bouncing between signed in and signed out when the load balancer switches pods. <code>SetApplicationName</code> matters because keys are isolated per application name, which otherwise derives from the content root path and can differ between images. The key ring should also be encrypted at rest; the Data Protection lesson covers the options.

Example · csharp
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;

var builder = WebApplication.CreateBuilder(args);

// Every replica must read and write the same key ring, under the same application name.
builder.Services.AddDataProtection()
    .SetApplicationName("shop-bff")
    .PersistKeysToFileSystem(new DirectoryInfo("/var/dpkeys")); // a shared volume; or Redis, blob storage, a database

builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options => options.Cookie.Name = "__Host-shop");
builder.Services.AddAuthorization();

var app = builder.Build();
app.MapGet("/bff/user", (System.Security.Claims.ClaimsPrincipal user) => TypedResults.Ok(new { name = user.Identity?.Name }))
    .RequireAuthorization();
app.Run();

Choosing redirect or status code per endpoint (.NET 10)

The .NET 10 default is driven by endpoint metadata: <code>TypedResults</code> and JSON-returning handlers add it, <code>Results.Ok</code> and plain strings do not. Rather than relying on return types, which a refactor can change, put <code>DisableCookieRedirect()</code> on the API route group so every endpoint under <code>/api</code> answers 401 and 403. <code>AllowCookieRedirect()</code> wins over the API metadata for the rare endpoint a browser opens directly.

Example · csharp
using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);
builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.LoginPath = "/login";
        options.AccessDeniedPath = "/forbidden";
    });
builder.Services.AddAuthorization();

var app = builder.Build();

// Untyped results and strings are not treated as API endpoints, so mark the group explicitly.
var api = app.MapGroup("/api").RequireAuthorization().DisableCookieRedirect();
api.MapGet("/invoices/{id:int}", (int id) => Results.Ok(new { id, amount = 120.00m }));
api.MapGet("/invoices/{id:int}/status", (int id) => "paid");

// A page-like endpoint that returns JSON but should still send the browser to the login page.
app.MapGet("/export/invoices", () => TypedResults.Ok(new[] { 1, 2, 3 }))
    .RequireAuthorization()
    .AllowCookieRedirect();

app.Run();

Discussion

  • Be the first to comment on this lesson.