JWT или cookie-аутентификация в ASP.NET Core 11: что выбрать?
Используйте cookie-аутентификацию для любого приложения, где единственным клиентом является браузер, а bearer-токены JWT приберегите для API, которые вызывают мобильные приложения, другие сервисы или сторонние клиенты. Вот полная матрица для принятия решения.
Если ваше приложение на ASP.NET Core 11 вызывает только браузер, используйте cookie-аутентификацию. Если вызывающая сторона — это мобильное приложение, другой сервис или сторонний клиент, который не может хранить cookie, используйте bearer-токены JWT. Единственная ось, которая решает вопрос, — это клиент: cookie — это механизм браузерной сессии с серверным отзывом и встроенным инструментарием против CSRF, тогда как JWT — это не сохраняющее состояние, самодостаточное удостоверение, которое может нести любой HTTP-клиент, но которое нельзя отозвать до истечения срока действия. Выбор JWT для сайта с серверным рендерингом или одностраничного приложения того же источника — самая распространённая ошибка безопасности в экосистеме .NET, потому что он без всякой пользы помещает долгоживущий токен в доступное для JavaScript хранилище. Этот пост подкрепляет данную рекомендацию матрицей возможностей, настройкой обеих схем в .NET 11 и подвохом, который вынуждает сделать выбор.
Всё здесь нацелено на .NET 11, ASP.NET Core 11 и C# 14. Обе схемы поставляются из коробки: cookie-аутентификация живёт в Microsoft.AspNetCore.Authentication.Cookies, а JWT bearer — в Microsoft.AspNetCore.Authentication.JwtBearer, причём последний обычно является единственной NuGet-ссылкой, которую вы добавляете. В .NET 11 ничего не изменилось в основном компромиссе, но фреймворк продолжает подталкивать вас к правильному значению по умолчанию: рекомендации OAuth по приложениям на основе браузера и паттерн Backend-for-Frontend оба ужесточили совет полностью держать токены вне браузера.
Матрица возможностей
| Возможность | Cookie-аутентификация | JWT bearer |
|---|---|---|
| Переносится в | заголовок Cookie (управляется браузером) | заголовок Authorization: Bearer |
| Состояние на сервере | с сохранением состояния (билет можно перепроверить) | без сохранения состояния (самодостаточные claims) |
| Отзывается до истечения срока | да (немедленно) | нет (нужен denylist или короткий TTL) |
| Устанавливается браузером автоматически | да | нет (клиент прикрепляет его) |
| Подверженность CSRF | да, нужны antiforgery-токены | нет (заголовок не отправляется автоматически) |
| Подверженность XSS для удостоверения | низкая (HttpOnly скрывает его от JS) | высокая, если хранится в доступном для JS хранилище |
| Работает для не-браузерных клиентов | неудобно | нативно |
| Кросс-доменность / несколько API | болезненно (правила области cookie) | легко (любой хост проверяет подпись) |
| Размер полезной нагрузки на запрос | маленькое непрозрачное значение типа id | полный токен, растёт с числом claims |
| Встроено в .NET 11 | да | да (AddJwtBearer) |
Строки, которые решают реальные проекты, — это отзыв, CSRF и XSS. Всё остальное — это водопровод.
Что на самом деле представляет собой каждая схема
Cookie-аутентификация выдаёт зашифрованный билет аутентификации после входа и хранит его в cookie, которую слой Data Protection в ASP.NET Core подписывает и шифрует. Браузер автоматически прикрепляет эту cookie к каждому запросу того же сайта. Сервер расшифровывает её, перестраивает ClaimsPrincipal и может выполнять OnValidatePrincipal на каждом запросе, чтобы перепроверять пользователя по базе данных, и именно так вы отзываете сессию в тот же момент, когда пользователь отключён.
// .NET 11, C# 14
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.HttpOnly = true; // hidden from document.cookie
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict; // blocks most CSRF by default
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
options.LoginPath = "/login";
});
Определяющее свойство в том, что значение cookie непрозрачно для JavaScript, когда установлен HttpOnly, а сервер хранит достаточно контекста (ключи Data Protection, опционально хранилище-бэкенд), чтобы признать её недействительной. Цена в том, что, поскольку браузер автоматически отправляет cookie на кросс-сайтовых запросах, вы наследуете CSRF и должны защищаться от него с помощью antiforgery-токенов.
JWT bearer-аутентификация проверяет самодостаточный токен в заголовке Authorization. Токен — это подписанный (и опционально зашифрованный) блоб claims. Серверной сессии нет: проверка — это чистая математика подписи и claims, поэтому любое количество сервисов может принимать один и тот же токен, зная ключ подписи или открытый ключ издателя. Клиент отвечает за хранение токена и прикрепление его к каждому запросу.
// .NET 11, C# 14
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://login.example.com"; // for key discovery
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://login.example.com",
ValidateAudience = true,
ValidAudience = "my-api",
ValidateLifetime = true,
ValidateIssuerSigningKey = true
};
});
Правильная настройка этих TokenValidationParameters — это место, где живёт большинство багов JWT; если вы отлаживаете токен, который фреймворк отвергает, смотрите как проверять издателя, аудиторию и срок действия JWT в ASP.NET Core 11. Обратная сторона в том, что вы не можете отменить выпуск действительного токена: как только он подписан и попал в руки клиента, он принимается до тех пор, пока не пройдёт exp, что бы ни случилось с учётной записью пользователя.
Когда выбирать cookie-аутентификацию
- Приложения с серверным рендерингом: MVC, Razor Pages или Blazor Server в .NET 11. Браузер — единственный клиент, он управляет cookie за вас, а
HttpOnlyдержит удостоверение вне досягаемости любого внедрённого скрипта. Для JavaScript нет токена, который можно было бы утечь. - Одностраничное приложение того же источника, общающееся со своим собственным бэкендом. Это случай, в котором люди чаще всего ошибаются. Если ваше приложение на React или Angular обслуживается с того же источника и вызывает его же, cookie одновременно проще и безопаснее, чем выпуск JWT и его сохранение в
localStorage. Рекомендации рабочей группы OAuth по приложениям на основе браузера явно направляют первостороние SPA к опирающемуся на cookie паттерну Backend-for-Frontend, а не к токенам в браузере. - Вам нужен мгновенный отзыв. Отключение пользователя, смена пароля или принудительный глобальный выход должны вступать в силу сейчас. С cookie вы перепроверяете principal на каждый запрос (
OnValidatePrincipal) или меняете ключ Data Protection, и следующий запрос становится неаутентифицированным. Нет ожидания истечения срока токена. - Вы используете ASP.NET Core Identity с локальными учётными записями. Стандартный UI Identity и
SignInManagerоснованы на cookie, и это поддерживаемый, первосторонний путь.
Налог, который вы принимаете с cookie, — это CSRF. Поскольку браузер отправляет cookie на кросс-сайтовых отправках форм, вам нужна antiforgery-защита на изменяющих состояние конечных точках. SameSite=Strict или Lax блокирует распространённые случаи, а antiforgery-токены ASP.NET Core закрывают остальное. Если эти токены перестают проходить проверку после развёртывания, это обычно проблема набора ключей Data Protection, описанная в почему antiforgery-токен не удалось расшифровать.
Когда выбирать JWT bearer
- REST- или gRPC-API, потребляемый мобильным или десктопным приложением. У нативного клиента нет хранилища cookie, привязанного к вашему домену, и он может хранить токен в связке ключей ОС или защищённом хранилище, что более безопасный дом, чем браузер. Bearer-токены здесь естественны.
- Вызовы сервис-сервис и микросервисы. Токен, подписанный центральным провайдером идентификации, может быть независимо проверен дюжиной сервисов, которые никогда не разделяют хранилище сессий. Это сценарий, где отсутствие состояния — это возможность, а не обуза.
- Сторонний доступ к API, где вы не контролируете клиента. Публичные API, партнёрские интеграции и всё, что управляется потоками OAuth 2.0 client-credentials или authorization-code, по своей конструкции живут на bearer-токенах.
- Кросс-доменные вызовы, до которых cookie не может чисто дотянуться. Если
app.comдолжен вызыватьapi.other.com, область видимости cookie борется с вами, тогда как bearer-токену безразличен источник. Если вы всё-таки направляете защищённый JWT вызов API из браузера на другом источнике, сложная часть — это обычно предварительный запрос, а не токен; смотрите как настроить CORS для защищённого JWT API в ASP.NET Core 11.
Налог, который вы принимаете с JWT, — это отзыв. Утёкший или украденный токен действителен до истечения срока, поэтому стандартное смягчение — это короткоживущие токены доступа (от 5 до 15 минут) в паре с дольше живущими refresh-токенами, которые вы можете отзывать на стороне сервера. Если вы выпускаете свои собственные токены, не пропускайте этот механизм; как реализовать refresh-токены в ASP.NET Core Identity проходит через части ротации и отзыва.
Почему “JWT в localStorage” — неправильное значение по умолчанию для браузеров
Причина, по которой это сравнение важно, в том, что популярный паттерн из туториалов по SPA — выпустить JWT при входе и сохранить его в localStorage — меняет несуществующую проблему на реальную. SPA того же источника не нуждается в отсутствии состояния; его бэкенд прямо здесь и может хранить сессию. Что оно получает взамен токена — это удостоверение, которое можно эксфильтровать через XSS. Любой скрипт, который выполняется на вашей странице, включая подтянутый через скомпрометированную npm-зависимость, может прочитать localStorage, похитить токен и воспроизвести его откуда угодно до истечения срока.
Cookie с HttpOnly вообще не может быть прочитана через document.cookie, поэтому тот же XSS, который опустошает токен из localStorage, не может напрямую украсть cookie. Это вся причина, по которой индустрия двинулась к паттерну Backend-for-Frontend: SPA аутентифицируется против своего собственного бэкенда с помощью безопасной, HttpOnly, SameSite cookie, а бэкенд держит любые вышестоящие OAuth-токены на стороне сервера, никогда не передавая их JavaScript. Текущий черновик IETF по приложениям на основе браузера рекомендует именно это, а фреймворк BFF от Duende упаковывает это для ASP.NET Core. Короткая версия: токены принадлежат браузеру только тогда, когда нет сервера, который мог бы держать их за вас, что для первостороннего приложения не бывает никогда.
Запуск обеих схем в одном приложении
Выбор одной схемы глобально не означает, что вы можете использовать только одну. Распространённая реальная форма — это сайт с серверным рендерингом плюс JSON API на том же хосте: cookie для страниц, JWT для API. Зарегистрируйте обе и выбирайте на каждую конечную точку.
// .NET 11, C# 14
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie()
.AddJwtBearer(); // second scheme, not the default
// Pages use the default cookie scheme:
app.MapGet("/dashboard", () => Results.Ok("hello"))
.RequireAuthorization();
// The API explicitly requires the bearer scheme:
app.MapGet("/api/orders", () => Results.Ok(orders))
.RequireAuthorization(new AuthorizeAttribute
{
AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme
});
Ключевая деталь: схема, которую вы передаёте первой в AddAuthentication, является значением по умолчанию, и любой [Authorize] без явной схемы использует её. Конечные точки, которые должны принимать bearer-токен, должны явно называть JwtBearerDefaults.AuthenticationScheme, иначе фреймворк попытается проверить cookie, не найдёт ни одной и ответит вызовом с перенаправлением на страницу входа вместо возврата 401. Это несоответствие, когда API возвращает HTML-перенаправление на вход вместо чистого 401, — частый симптом неправильно настроенной схемы по умолчанию. Родственный вариант, когда API отвечает 405 вместо 401, и более широкий класс проблем “действительный токен всё равно отвергается” стоит знать, прежде чем вы выкатите смешанную настройку: смотрите почему JWT в ASP.NET Core возвращает 401 даже с действительным токеном.
Подвох, который делает выбор за вас
Задержка отзыва — это вынуждающий фактор. Задайте один вопрос: когда доступ пользователя должен быть отрезан, как долго вы можете терпеть, что старое удостоверение всё ещё работает?
- Если ответ “ноль, оно должно остановиться немедленно” (банкинг, консоли администратора, всё, где скомпрометированная или уволенная учётная запись является активной угрозой), побеждает cookie, потому что вы можете признать сессию недействительной на следующем запросе. Чтобы получить такое же поведение от JWT, вам придётся прикрутить серверный denylist, что снова вводит ровно тот поиск с сохранением состояния, которого вы избегали, перейдя на JWT.
- Если ответ “пара минут — это нормально”, короткоживущие JWT с refresh-токенами приемлемы, и вы сохраняете отсутствие состояния, которое делает их привлекательными между сервисами.
Второй вынуждающий фактор — это клиент. Cookie — это конструкция браузера. В тот момент, когда не-браузерный клиент должен аутентифицироваться, cookie перестаёт быть естественной, а bearer-токен становится очевидным носителем. Если у вашего приложения есть оба вида вызывающих сторон, это не ничья, это сигнал запустить обе схемы, как показано выше, каждую на тех конечных точках, которые ей подходят.
Рекомендация, повторенная
Для приложения, чей единственный клиент — браузер, включая SPA того же источника, используйте cookie-аутентификацию: HttpOnly, Secure, SameSite, с antiforgery на изменяющих состояние конечных точках. Вы получаете удостоверение, которое JavaScript не может прочитать, и отзыв, который вступает в силу на следующем запросе, и вы не отказываетесь ни от чего, в чём реально нуждается первостороннее веб-приложение. Тянитесь к JWT bearer, когда вызывающая сторона — это мобильное или десктопное приложение, другой сервис или третья сторона, где отсутствие состояния является подлинным преимуществом и нет серверной сессии, на которую можно опереться. Когда существуют оба вида клиентов, зарегистрируйте обе схемы и выбирайте на каждую конечную точку, а не вынуждайте одну делать работу другой. Решение не о том, какая технология более современна; оно о том, кто держит удостоверение и как быстро вам нужно его забрать.
Связанное
- Как проверять издателя, аудиторию и срок действия JWT в ASP.NET Core 11
- Почему JWT в ASP.NET Core возвращает 401 даже с действительным токеном
- Как настроить CORS для защищённого JWT API в ASP.NET Core 11
- Как реализовать refresh-токены в ASP.NET Core Identity
- Почему antiforgery-токен не удалось расшифровать в ASP.NET Core
Источники
- Overview of ASP.NET Core authentication (Microsoft Learn)
- Use cookie authentication without ASP.NET Core Identity (Microsoft Learn)
- Authentication and authorization in minimal APIs / JWT bearer (Microsoft Learn)
- Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks (Microsoft Learn)
- OAuth 2.0 for Browser-Based Apps (IETF draft)
- Securing SPAs using the BFF Pattern (Duende Software)
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.