Everything works fine in development. You log in, you get a cookie, life is good. You deploy to production, and a portion of your users can't authenticate. No useful error message. The browser just silently fails or shows a generic "something went wrong" page. Your logs contain 431 Request Header Fields Too Large or requests that arrive with a valid session cookie that your app promptly rejects.
The culprit is almost always cookie size. This post walks through why auth cookies grow out of control, how to spot the symptoms, and concrete solutions you can apply today.
Why Auth Cookies Get Chonky (Large)
The default ASP.NET Core cookie authentication handler serializes your entire ClaimsPrincipal into the cookie. Every claim attached to the principal gets written into that cookie. In a simple application, this is fine because a handful of claims fits comfortably in a cookie.
Problems start when any of these happen:
Too many claims. An application that maps every user attribute into claims can easily end up with dozens of entries: roles, permissions, department codes, feature flags, and tenant metadata. Each claim is a key-value pair, and they accumulate fast. Role-heavy systems are especially prone to this. A user with 30 role assignments can produce a cookie that exceeds browser limits before you've added any other claims.
Multiple authentication schemes. When you run multiple cookie schemes side-by-side (external provider cookies, application session cookies, multi-tenancy schemes), each one writes its own cookie. Even if no single cookie is oversized, the combined header payload on every request may push you past the 8 KB limit that many web servers and proxies enforce.
ID tokens stored in the session. If you're using OpenID Connect and storing the id_token in the cookie (which is the default behavior of the OIDC handler), you're serializing a base64-encoded JWT into the cookie on every request. An ID token with a large payload can add 1-2 KB on its own.
Group or permission claims from an external IdP. When a user authenticates via Microsoft Entra/Azure AD, Okta, or Google Workspace, and you have group membership claims enabled, the IdP can include dozens — sometimes hundreds — of group identifiers in the token. If all of those get mapped into your application cookie, you're in trouble.
Symptoms to Look For
Silent authentication failures are the most frustrating symptom. The browser sends a cookie, but the cookie is truncated at the browser level (most browsers enforce a 4 KB limit per cookie), so the server receives a partial, invalid payload. Deserialization fails silently, and the user is treated as unauthenticated.
HTTP 431 Request Header Fields Too Large appears when the total header payload crosses the server's limit. You'll see this more often with reverse proxies (nginx, Apache, IIS ARR) than with Kestrel directly, because proxies tend to enforce stricter header limits.
Intermittent logout. A user with a borderline-sized cookie might authenticate fine most of the time, but after accumulating additional claims through a role assignment or group membership change, their next login suddenly fails.
Load balancer or WAF blocking requests. Some web application firewalls have limits as low as 4 KB on individual header values. Users on those networks will appear to be perpetually logged out.
You can verify the cookie size is the issue by decoding the base64-encoded cookie value from the browser's DevTools (Application > Cookies) and checking its byte length. Anything over 3 KB is worth investigating.
Fix #1: Clearing Previous Cookies
When a server issues cookies, the expectation is that they'll be around for a while. The stickiness of cookies, combined with their imperceptibility, can lead to a cookie pile-up. When working with external authentication in Duende IdentityServer, a strong recommendation, we ask developers to first call SignOutAsync before invoking Challenge. The SignOutAsync call will ensure any existing (and irrelevant) cookies from the external provider are removed before a new one is created.
Fix #2: Don't Store Tokens In Cookies
When working with external OpenID Connect providers, the default behavior is to save tokens when issuing the cookie. Saving tokens enables you to access APIs or properly sign out with the id_token_hint. That said, external providers may not be necessary in every case. Storing unnecessary values may lead to unexpected behavior and errors, as tokens from external providers can be enormous and beyond your control. If you are using external authentication with Duende IdentityServer, you may want to disable token storage.
services.AddAuthentication()
.AddOpenIdConnect(options =>
{
options.SaveTokens = false;
});
Fix #3: Set MapInboundClaims to false
When using external authentication with Microsoft-based products such as ADFS or Entra ID, you may want to set MapInboundClaims to false when calling AddOpenIdConnect to prevent claims from the external provider from being mapped.
services.AddAuthentication()
.AddOpenIdConnect(options =>
{
options.MapInboundClaims = false;
});
Microsoft's namespace for external claims is typically prefixed with http://schemas.microsoft.com/identity/claims/, which is significantly larger than the claim names used by OpenID Connect and can take up unnecessary space. A remnant of the Windows Auth and XML-heavy 2000s, these verbose identifiers should almost never appear in a cookie.
Fix #4: Reduce Cookie Size with OnTicketReceived
When your application externalizes authentication with Duende IdentityServer using OpenID Connect, you can implement the OIDC handler's OnTicketReceived callback to reduce cookie size by removing unnecessary claims. The callback is invoked after the external authentication process is complete, but before the authentication cookie is stored in the browser.
You can use this callback to remove any claims that are not needed by your solution. Here's an example that removes unused-claim:
services.AddAuthentication()
.AddOpenIdConnect("oidc", options =>
{
// ...
// Remove claims that are not needed
options.Events = new OpenIdConnectEvents()
{
OnTicketReceived = context =>
{
var identities = context.Principal?.Identities ?? Enumerable.Empty<ClaimsIdentity>();
foreach (var identity in identities)
{
var removedClaim = identity.FindFirst("unused-claim");
identity.TryRemoveClaim(removedClaim);
}
return Task.CompletedTask;
}
};
});
Fix #5: Server-Side Sessions
The cleanest fix is to stop storing session data in the cookie entirely. Duende IdentityServer's server-side sessions feature moves the session payload to the server and replaces it with a small opaque session reference in the cookie. The cookie becomes a thin key that points to the server state rather than containing that state itself.
Enable it in your IdentityServer setup:
builder.Services.AddIdentityServer()
.AddServerSideSessions();
This requires a backing store. For the Entity Framework operational store, the session table is included automatically. For a custom store, implement IServerSideSessionStore.
On the client side, if you're building an ASP.NET Core application that is not Duende IdentityServer itself, you get a similar benefit from Duende BFF, which stores tokens server-side and sends only a simple session cookie to the browser. If you're just using ASP.NET Core cookie authentication directly, you can achieve the same result with session-backed ticket stores:
builder.Services.AddDistributedMemoryCache(); // Or Redis, SQL Server, etc.
// Custom ITicketStore implementation that persists the ticket in IDistributedCache
builder.Services.AddSingleton<ITicketStore, DistributedCacheTicketStore>();
builder.Services.AddOptions<CookieAuthenticationOptions>(
CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<ITicketStore>((options, store) =>
options.SessionStore = store);
A minimal ITicketStore backed by IDistributedCache:
public class DistributedCacheTicketStore : ITicketStore
{
private const string KeyPrefix = "auth-ticket-";
private readonly IDistributedCache _cache;
public DistributedCacheTicketStore(IDistributedCache cache)
{
_cache = cache;
}
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
var key = KeyPrefix + Guid.NewGuid().ToString("N");
await RenewAsync(key, ticket);
return key;
}
public async Task RenewAsync(string key, AuthenticationTicket ticket)
{
var options = new DistributedCacheEntryOptions();
var expiresUtc = ticket.Properties.ExpiresUtc;
if (expiresUtc.HasValue)
{
options.SetAbsoluteExpiration(expiresUtc.Value);
}
var ticketBytes = TicketSerializer.Default.Serialize(ticket);
await _cache.SetAsync(key, ticketBytes, options);
}
public async Task<AuthenticationTicket?> RetrieveAsync(string key)
{
var bytes = await _cache.GetAsync(key);
if (bytes is null) return null;
return TicketSerializer.Default.Deserialize(bytes);
}
public Task RemoveAsync(string key)
{
return _cache.RemoveAsync(key);
}
}
In production, swap AddDistributedMemoryCache for a shared cache (Redis, SQL Server) so the store works across multiple application instances. For developers using Redis, remember to enable persistence to avoid losing data prematurely and to size your Redis storage appropriately.
Fix #6: Chunked Cookies
If you want to keep the claims in the cookie while working within the browser's per-cookie size limits, ASP.NET Core's cookie authentication middleware supports chunking. Chunking splits a single logical cookie into multiple Set-Cookie headers, each within the browser limit, and reassembles them on the server side.
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "app.auth";
// Split cookies at 3500 bytes to stay safely under the 4 KB per-cookie limit
options.CookieManager = new ChunkingCookieManager
{
ChunkSize = 3500
};
});
ChunkingCookieManager is built into Microsoft.AspNetCore.Authentication.Cookies — no additional packages needed. It handles the split/reassemble logic transparently.
This is a good quick fix, but it doesn't solve the underlying problem. If your cookie payload grows past 12-16 KB, even chunking will struggle (browsers typically cap the number of cookies per domain). Use this as a stopgap while you address the root cause.
Fix #7: Claim Filtering via Configuration
The most sustainable fix is to stop putting so many claims in tokens and cookies in the first place. If you're using Duende IdentityServer, use the configuration system to declare exactly which claims each client needs.
Define API scopes with specific user claims:
public static IEnumerable<ApiScope> ApiScopes =>
new List<ApiScope>
{
// Scope for basic API access - only essential claims
new ApiScope(
name: "api.read",
displayName: "Read API access",
userClaims: new[] { "sub", "email" }),
// Scope for admin operations - includes role and department
new ApiScope(
name: "api.admin",
displayName: "Admin API access",
userClaims: new[] { "sub", "email", "role", "department_id" }),
// Scope for tenant-specific operations
new ApiScope(
name: "api.tenant",
displayName: "Tenant API access",
userClaims: new[] { "sub", "email", "tenant_id" })
};
Then configure each client to request only the scopes it needs:
public static IEnumerable<Client> Clients =>
new List<Client>
{
// Mobile app - minimal claims
new Client
{
ClientId = "mobile_app",
AllowedGrantTypes = GrantTypes.Code,
RequireClientSecret = false,
RequirePkce = true,
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
"api.read" // Only gets sub, email
}
},
// Admin dashboard - full claims
new Client
{
ClientId = "admin_dashboard",
AllowedGrantTypes = GrantTypes.Code,
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
"api.admin" // Gets sub, email, role, department_id
}
}
};
That's it. Duende IdentityServer's default profile service automatically respects the UserClaims declared in your scopes and filters tokens accordingly. No custom code needed.
The key principle is to load data on demand in your APIs (via token introspection or a database lookup using the sub claim) rather than embedding everything in the token upfront. Roles and permissions are often better handled through an authorization service call during API request processing than by being baked into a JWT that must be small enough to fit in a browser cookie.
For the OIDC handler on the client side, you can also prevent the id_token from being saved in the cookie:
builder.Services.AddAuthentication()
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://your-identity-server";
options.ClientId = "your-client";
options.ResponseType = "code";
// Don't store the raw id_token in the cookie
options.SaveTokens = false;
// If you need access tokens for API calls, use BFF or a server-side token store
});
Fix #8: Custom Claim Filtering in IProfileService
If you need more advanced filtering logic beyond what the configuration system provides—such as loading claims from a custom data source, applying user-specific rules, or validating user state—you can implement a custom IProfileService:
public class FilteredProfileService : IProfileService
{
private readonly IUserClaimsPrincipalFactory<ApplicationUser> _claimsFactory;
private readonly UserManager<ApplicationUser> _userManager;
public FilteredProfileService(
IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory,
UserManager<ApplicationUser> userManager)
{
_claimsFactory = claimsFactory;
_userManager = userManager;
}
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var user = await _userManager.GetUserAsync(context.Subject);
if (user is null) return;
var principal = await _claimsFactory.CreateAsync(user);
// Filter to only the claims declared in the scope configuration
var requestedClaimTypes = context.RequestedClaimTypes;
var claims = principal.Claims
.Where(c => requestedClaimTypes.Contains(c.Type))
.ToList();
// Add custom logic here if needed (e.g., load claims from a database)
// claims.AddRange(await LoadCustomClaimsAsync(user));
context.IssuedClaims = claims;
}
public async Task IsActiveAsync(IsActiveContext context)
{
var user = await _userManager.GetUserAsync(context.Subject);
context.IsActive = user?.IsEnabled ?? false;
}
}
Register it:
builder.Services.AddIdentityServer()
.AddAspNetIdentity<ApplicationUser>()
.AddProfileService<FilteredProfileService>();
This approach is useful when you need to enforce additional business logic, such as checking if a user is still active or loading claims from an external system. For most scenarios, Fix #7 (configuration-based filtering) is sufficient and requires no custom code.
Choosing the Right Fix
Use server-side sessions if you need to store large amounts of user state and you're already running Duende IdentityServer. It's the most complete solution and adds session management capabilities (you can revoke sessions, query active sessions, and enforce inactivity timeouts).
Use configuration-based claim filtering (Fix #7) if the problem is too many claims. Define which claims each scope needs, and let the default profile service handle the filtering. This is the correct architectural fix for most scenarios, and it benefits every part of your system — smaller tokens, faster validation, less network overhead.
Use custom IProfileService filtering (Fix #8) only if you need advanced logic beyond configuration, such as loading claims from a custom data source or enforcing user-specific rules.
Use chunked cookies as a tactical fix when you need to unblock users immediately and can't do a larger refactor right now. It's a valid interim solution.
Most production systems end up with a combination: filtered claims in the token via scope configuration, server-side sessions for the IdentityServer, and careful scope configuration on clients so they only request the claims they actually need.
The 4 KB browser cookie limit has been there since the early days of the web. It's not going anywhere. Building around it is just part of shipping reliable authentication at scale.
P.S.: Check out our deployment troubleshooting documentation for other common production issues and suggested solutions.
Thanks for stopping by!
We hope this post helped you on your identity and security journey. If you need a hand with implementation, our docs are always open. For everything else, come hang out with the team and other developers on GitHub.
If you want to get early access to new features and products while collaborating with experts in security and identity standards, join us in our Duende Product Insiders program. And if you prefer your tech content in video form, our YouTube channel is the place to be. Don't forget to like and subscribe!
Questions? Comments? Just want to say hi? Leave a comment below and let's start a conversation.