Ситуация: в среде разработки всё работает отлично. Пользователь входит в систему, получает cookie, и никаких проблем не возникает. Но после деплоя в продакшен часть пользователей внезапно перестаёт проходить аутентификацию. Никаких внятных сообщений об ошибке. Браузер либо молча завершает запрос с ошибкой, либо показывает что-то вроде «что-то пошло не так». В журналах появляются ошибки 431 Request Header Fields Too Large или запросы с корректной cookie сессии, которые приложение тут же отклоняет.
Причина почти всегда одна — размер cookie. В этой статье разберём, почему cookie аутентификации бесконтрольно разрастаются, как распознать симптомы проблемы и какие практические решения можно применить уже сейчас.
Почему cookie аутентификации становятся огромными
Обработчик cookie-аутентификации ASP.NET Core по умолчанию сериализует весь объект ClaimsPrincipal в cookie. Каждое утверждение (claim), связанное с этим объектом, записывается в cookie. Для простого приложения это нормально: несколько утверждений спокойно помещаются в допустимый размер cookie.
Проблемы начинаются в следующих случаях:
Слишком много утверждений. Приложение, которое преобразует каждый пользовательский атрибут в утверждение, очень быстро получает десятки записей: роли, разрешения, коды подразделений, флаги функциональности и метаданные арендатора. Каждое утверждение — это пара ключ-значение, и их объём растёт стремительно. Особенно уязвимы системы с большим количеством ролей. Пользователь с 30 назначенными ролями может получить cookie, превышающую лимиты браузера, ещё до добавления остальных утверждений.
Несколько схем аутентификации. Если приложение одновременно использует несколько cookie-схем (cookie внешних провайдеров, cookie сессий приложения, схемы для мультиарендности), каждая схема создаёт собственную cookie. Даже если размер каждой отдельной cookie не превышает лимиты, суммарный объём заголовков в каждом запросе может превысить ограничение в 8 КБ, которое накладывают многие веб-серверы и прокси.
ID-токены, хранящиеся в сессии. Если вы используете OpenID Connect и сохраняете id_token в cookie (а именно так по умолчанию работает обработчик OIDC), то в cookie сериализуется JWT в кодировке Base64 при каждом запросе. Один ID-токен с большим набором данных может добавить к размеру cookie ещё 1–2 КБ.
Групповые утверждения или утверждения разрешений от внешнего поставщика удостоверений (IdP). Когда пользователь проходит аутентификацию через Microsoft Entra/Azure AD, Okta или Google Workspace, а передача групповых утверждений включена, IdP может добавить в токен десятки, а иногда и сотни идентификаторов групп. Если все они попадут в cookie вашего приложения, проблемы практически гарантированы.
На какие симптомы обратить внимание
Самый неприятный симптом — тихие сбои аутентификации. Браузер отправляет cookie, но она обрезается на уровне браузера: большинство браузеров ограничивают размер одной cookie 4 КБ. В результате сервер получает неполную, некорректную полезную нагрузку. Десериализация завершается без явной ошибки, и пользователь считается неаутентифицированным.
HTTP 431 Request Header Fields Too Large появляется, когда суммарный размер заголовков превышает серверный лимит. Чаще это видно при работе через обратные прокси (nginx, Apache, IIS ARR), а не напрямую с Kestrel, потому что прокси обычно строже ограничивают размер заголовков.
Периодические выходы из системы. Пользователь с cookie на грани допустимого размера может большую часть времени проходить аутентификацию нормально. Но после добавления новых утверждений через назначение роли или изменение членства в группе следующий вход внезапно завершается сбоем.
Блокировка запросов балансировщиком нагрузки или WAF. Некоторые межсетевые экраны веб-приложений (WAF) ограничивают отдельные значения заголовков всего 4 КБ. Для пользователей из таких сетей это будет выглядеть так, будто их постоянно выкидывает из системы.
Проверить, что проблема именно в размере cookie, можно так: возьмите значение cookie в инструментах разработчика браузера (Application > Cookies), декодируйте его из Base64 и проверьте длину в байтах. Всё, что превышает 3 КБ, уже стоит изучить внимательнее.
Способ 1. Очистить старые cookie
Когда сервер выпускает cookie, предполагается, что они будут жить какое-то время. «Липкость» cookie в сочетании с тем, что пользователь их почти не замечает, может привести к накоплению лишних cookie. При работе с внешней аутентификацией в Duende IdentityServer мы настоятельно рекомендуем разработчикам сначала вызывать SignOutAsync и только потом Challenge. Вызов SignOutAsync гарантирует, что все существующие и уже неактуальные cookie от внешнего провайдера будут удалены до создания новой cookie.
Способ 2. Не хранить токены в cookie
При работе с внешними провайдерами OpenID Connect поведение по умолчанию — сохранять токены при выпуске cookie. Сохранение токенов позволяет обращаться к API или корректно выходить из системы с помощью id_token_hint. При этом внешние токены нужны не во всех сценариях. Хранение лишних значений может приводить к неожиданному поведению и ошибкам, поскольку токены от внешних провайдеров бывают огромными, а вы не контролируете их размер. Если вы используете внешнюю аутентификацию с Duende IdentityServer, стоит отключить сохранение токенов.
services.AddAuthentication() .AddOpenIdConnect(options => { options.SaveTokens = false; });
Способ 3. Установить MapInboundClaims в false
При использовании внешней аутентификации с продуктами Microsoft, например ADFS или Entra ID, можно установить MapInboundClaims в false при вызове AddOpenIdConnect, чтобы утверждения от внешнего провайдера не преобразовывались автоматически.
services.AddAuthentication() .AddOpenIdConnect(options => { options.MapInboundClaims = false; });
Пространство имён Microsoft для внешних утверждений обычно начинается с http://schemas.microsoft.com/identity/claims/. Оно значительно длиннее имён утверждений, используемых в OpenID Connect, и может занимать лишнее место. Эти многословные идентификаторы — наследие Windows Auth и XML-тяжёлых 2000-х, и им почти никогда не место в cookie.
Способ 4. Уменьшить размер cookie с помощью OnTicketReceived
Если ваше приложение выносит аутентификацию в Duende IdentityServer через OpenID Connect, можно реализовать обратный вызов (callback) OnTicketReceived в обработчике OIDC, чтобы уменьшить размер cookie за счёт удаления ненужных утверждений. Этот обратный вызов вызывается после завершения внешней аутентификации, но до того, как cookie аутентификации будет сохранена в браузере.
С помощью этого обратного вызова можно удалить любые утверждения, которые вашему решению не нужны. Вот пример, в котором удаляется unused-claim:
services.AddAuthentication() .AddOpenIdConnect("oidc", options => { // ... // Удаляем утверждения, которые не нужны 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; } }; });
Способ 5. Серверные сессии
Самое чистое решение — полностью перестать хранить данные сессии в cookie. Механизм серверных сессий Duende IdentityServer переносит полезную нагрузку сессии на сервер и заменяет её в cookie небольшой непрозрачной ссылкой на сессию. Cookie становится тонким ключом, который указывает на состояние на сервере, а не содержит это состояние внутри себя.
Включите это в настройках IdentityServer:
builder.Services.AddIdentityServer() .AddServerSideSessions();
Для этого требуется хранилище. При использовании операционного хранилища Entity Framework таблица сессий включается автоматически. Для собственного хранилища нужно реализовать IServerSideSessionStore.
На стороне клиента, если вы создаёте ASP.NET Core-приложение, которое само не является Duende IdentityServer, похожий результат даёт Duende BFF: он хранит токены на сервере и отправляет браузеру только простую cookie сессии. Если вы напрямую используете cookie-аутентификацию ASP.NET Core, того же эффекта можно добиться с помощью хранилищ билетов, привязанных к сессии:
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);
Минимальная реализация ITicketStore на базе 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); } }
В продакшене замените AddDistributedMemoryCache на общий кэш, например Redis или SQL Server, чтобы хранилище работало между несколькими экземплярами приложения. Разработчикам, использующим Redis, стоит не забыть включить сохранение данных на диск, чтобы не потерять их преждевременно, а также правильно рассчитать объём хранилища Redis.
Способ 6. Разделённые cookie
Если вы хотите оставить утверждения в cookie, но уложиться в браузерные ограничения на размер одной cookie, промежуточное ПО cookie-аутентификации ASP.NET Core поддерживает разделение cookie на части. При таком подходе одна логическая cookie разбивается на несколько заголовков Set-Cookie, каждый из которых укладывается в лимит браузера, а затем собирается обратно на стороне сервера.
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 встроен в Microsoft.AspNetCore.Authentication.Cookies — дополнительные пакеты не нужны. Он прозрачно обрабатывает разбиение и последующую сборку cookie.
Это хороший быстрый способ исправить проблему, но он не устраняет её первопричину. Если полезная нагрузка cookie вырастет больше 12–16 КБ, даже разделение на части начнёт давать сбои: браузеры обычно ограничивают количество cookie на один домен. Используйте этот вариант как временную меру, пока устраняете корневую причину.
Способ 7. Фильтрация утверждений через конфигурацию
Самое устойчивое решение — изначально не помещать в токены и cookie слишком много утверждений. Если вы используете Duende IdentityServer, задайте в конфигурации, какие именно утверждения нужны каждому клиенту.
Определите области API с конкретными пользовательскими утверждениями:
public static IEnumerable<ApiScope> ApiScopes => new List<ApiScope> { // Область для базового доступа к API — только необходимые утверждения new ApiScope( name: "api.read", displayName: "Read API access", userClaims: new[] { "sub", "email" }), // Область для административных операций — включает роль и подразделение new ApiScope( name: "api.admin", displayName: "Admin API access", userClaims: new[] { "sub", "email", "role", "department_id" }), // Область для операций конкретного арендатора new ApiScope( name: "api.tenant", displayName: "Tenant API access", userClaims: new[] { "sub", "email", "tenant_id" }) };
Затем настройте каждого клиента так, чтобы он запрашивал только нужные ему области:
public static IEnumerable<Client> Clients => new List<Client> { // Мобильное приложение — минимальный набор утверждений new Client { ClientId = "mobile_app", AllowedGrantTypes = GrantTypes.Code, RequireClientSecret = false, RequirePkce = true, AllowedScopes = { IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile, "api.read" // Получает только sub, email } }, // Административная панель — полный набор утверждений new Client { ClientId = "admin_dashboard", AllowedGrantTypes = GrantTypes.Code, ClientSecrets = { new Secret("secret".Sha256()) }, AllowedScopes = { IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile, "api.admin" // Получает sub, email, role, department_id } } };
Готово. Стандартный сервис профиля Duende IdentityServer автоматически учитывает UserClaims, объявленные в ваших областях, и соответствующим образом фильтрует токены. Пользовательский код не нужен.
Ключевой принцип — загружать данные в API по требованию, например через интроспекцию токена или запрос к базе данных по утверждению sub, а не заранее встраивать всё в токен. Роли и разрешения часто лучше обрабатывать через вызов сервиса авторизации во время обработки API-запроса, а не зашивать их в JWT, который должен быть достаточно маленьким, чтобы поместиться в cookie браузера.
Для обработчика OIDC на стороне клиента также можно запретить сохранение id_token в cookie:
builder.Services.AddAuthentication() .AddOpenIdConnect("oidc", options => { options.Authority = "https://your-identity-server"; options.ClientId = "your-client"; options.ResponseType = "code"; // Не сохраняем сырой id_token в cookie options.SaveTokens = false; // If you need access tokens for API calls, use BFF or a server-side token store });
Способ 8. Пользовательская фильтрация утверждений в IProfileService
Если вам нужна более сложная логика фильтрации, чем позволяет конфигурация, например загрузка утверждений из собственного источника данных, применение правил для конкретного пользователя или проверка состояния пользователя, можно реализовать собственный 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); // Фильтруем только утверждения, объявленные в конфигурации области var requestedClaimTypes = context.RequestedClaimTypes; var claims = principal.Claims .Where(c => requestedClaimTypes.Contains(c.Type)) .ToList(); // При необходимости добавьте здесь пользовательскую логику, например загрузку утверждений из базы данных context.IssuedClaims = claims; } public async Task IsActiveAsync(IsActiveContext context) { var user = await _userManager.GetUserAsync(context.Subject); context.IsActive = user?.IsEnabled ?? false; } }
Зарегистрируйте его:
builder.Services.AddIdentityServer() .AddAspNetIdentity<ApplicationUser>() .AddProfileService<FilteredProfileService>();
Такой подход полезен, когда нужно применять дополнительную бизнес-логику, например проверять, активен ли пользователь, или загружать утверждения из внешней системы. В большинстве сценариев достаточно способа 7 — фильтрации на основе конфигурации. Он не требует пользовательского кода.
Выбор подходящего решения
Используйте серверные сессии, если вам нужно хранить большие объёмы пользовательского состояния и вы уже используете Duende IdentityServer. Это самое полное решение: оно добавляет возможности управления сессиями, например отзыв сессий, просмотр активных сессий и применение тайм-аутов неактивности.
Используйте фильтрацию утверждений на основе конфигурации (способ 7), если проблема в слишком большом количестве утверждений. Определите, какие утверждения нужны каждой области, и дайте стандартному сервису профиля выполнить фильтрацию. Для большинства сценариев это правильное архитектурное решение: оно помогает всей системе — токены становятся меньше, проверка выполняется быстрее, а сетевые накладные расходы снижаются.
Используйте пользовательскую фильтрацию через IProfileService (способ 8) только если вам нужна логика сложнее конфигурационной, например загрузка утверждений из собственного источника данных или применение правил для отдельных пользователей.
Используйте разделённые cookie как тактическое решение, когда нужно срочно разблокировать пользователей, а большой рефакторинг прямо сейчас невозможен. Это нормальный промежуточный вариант.
В большинстве продакшен-систем в итоге используют комбинацию подходов: фильтруют утверждения в токене через конфигурацию областей, включают серверные сессии для IdentityServer и аккуратно настраивают области на клиентах, чтобы они запрашивали только действительно нужные утверждения.
Браузерное ограничение cookie в 4 КБ существует с ранних времён веба. Оно никуда не денется. Проектировать систему с учётом этого ограничения — просто часть работы над надёжной аутентификацией в масштабе.

Если после этой статьи хочется не просто почистить cookie, а шире разобраться, как в ASP.NET Core устроены аутентификация, авторизация и запуск приложения в продакшене, можно присмотреться к открытым урокам OTUS. Они бесплатно проходят в рамках онлайн-курсов и дают возможность задать вопросы экспертам и сверить свои подходы с реальными инженерными сценариями.
26 мая в 20:00. «Аутентификация и авторизация в ASP.NET Core: от простого логина до production-ready реализации». Записаться
18 июня в 20:00. «Хостинг ASP.NET Core изнутри». Записаться
Полный список бесплатных уроков от преподавателей курсов уже доступен в календаре мероприятий.