Для сайта, где браузер и сервер - одно приложение, используйте аутентификацию через cookie. Для API, который вызывают мобильные приложения, другие серверы или single-page приложение на другом домене, используйте bearer-токены - обычно JWT, выданные провайдером идентификации. ASP.NET Core Identity - это хранилище пользователей и логика паролей, а не третья схема аутентификации; она работает под любой из двух. Что бы вы ни выбрали, большинство размещённых на хостинге приложений ломает вовсе не выбор, а ключи data protection, которые живут внутри контейнера и исчезают при повторном деплое, разлогинивая каждого пользователя и ломая каждую форму. Эта статья разбирает выбор, настройку каждого варианта, значения Identity по умолчанию и правильное хранение этих ключей.
Аутентификация, авторизация и порядок middleware#
Два слова, две задачи. Аутентификация выясняет, кто вызывающий, по cookie, токену или сертификату, и строит ClaimsPrincipal. Авторизация решает, можно ли этому principal делать то, что он просит. ASP.NET Core держит их раздельно: для первой - обработчик на каждую схему, для второй - политики.
builder.Services.AddAuthentication(/* default scheme */);builder.Services.AddAuthorization();var app = builder.Build();app.UseForwardedHeaders(); // behind a proxy: before anything that reads scheme or IPapp.UseRouting();app.UseAuthentication();app.UseAuthorization();app.MapControllers();WebApplication сам добавляет middleware аутентификации и авторизации, когда их сервисы зарегистрированы, но если прописать их явно, порядок остаётся на виду, а порядок важен: аутентификация должна выполняться до авторизации, и обе - после маршрутизации, чтобы были известны метаданные эндпоинта вроде [Authorize]. Forwarded headers идут первыми, когда приложение стоит за обратным прокси, иначе приложение считает, что каждый запрос пришёл по обычному HTTP, и cookies с пометкой secure, URL перенаправлений и OAuth-коллбэки - всё идёт не так. Точная конфигурация есть в статье ASP.NET Core за обратным прокси.
Cookies или JWT: выбор по клиенту#
| Клиент | Что использовать | Почему |
|---|---|---|
| Сайт с серверным рендерингом (Razor Pages, MVC, Blazor Server) | Cookies | Браузер сам их хранит и отправляет; в JavaScript ничего не нужно делать |
| SPA, отдаваемое с того же домена, что и API | Cookies | По тем же причинам, плюс HttpOnly держит учётные данные подальше от скриптов |
| SPA на другом домене | Токены или backend-for-frontend с cookies | Межсайтовые cookies браузеры блокируют всё чаще |
| Мобильное или десктопное приложение | Токены | Нет хранилища cookies, на которое можно положиться |
| Сервер к серверу | Токены (client credentials) | Нет ни пользователя, ни браузера |
Довод, что JWT «stateless и потому лучше», в основном о масштабе, которого у вас нет. Подписанный cookie точно так же stateless - auth cookie в ASP.NET Core содержит зашифрованные claims, а не ID сессии, - и у него есть два свойства, которых нет у токенов в JavaScript: хранением и сроком действия занимается браузер, а cookie с HttpOnly не может прочитать внедрённый скрипт. Токены оправданы, когда клиент - не браузер или когда их выдаёт отдельный провайдер идентификации для нескольких API.
Аутентификация через cookie#
builder.Services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/account/login"; options.AccessDeniedPath = "/account/denied"; options.ExpireTimeSpan = TimeSpan.FromDays(7); options.SlidingExpiration = true; options.Cookie.Name = "app_auth"; options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.SameSite = SameSiteMode.Lax; });Значения по умолчанию разумные: ExpireTimeSpan равен 14 дням, SlidingExpiration включён (cookie перевыпускается, когда прошло больше половины срока его жизни), cookie помечен HttpOnly, а SameSite равен Lax. Вход - это построение principal и передача его обработчику:
var claims = new List<Claim>{ new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(ClaimTypes.Name, user.Email), new(ClaimTypes.Role, user.Role)};var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), new AuthenticationProperties { IsPersistent = rememberMe });IsPersistent = false создаёт сессионный cookie, который исчезает при закрытии браузера; true даёт ему срок действия, указанный выше.
На двух вещах люди спотыкаются. Для API-эндпоинта обработчик cookie по умолчанию отвечает на неаутентифицированный запрос редиректом 302 на страницу входа, а JavaScript-клиент получает это как HTML-страницу со статусом 200. Переопределите options.Events.OnRedirectToLogin, чтобы для путей API возвращался 401. И поскольку браузер отправляет cookies автоматически, аутентификации через cookie нужна защита от подделки запросов (antiforgery) для запросов, меняющих состояние: MVC и Razor Pages проверяют токены в формах, а начиная с .NET 8 эндпоинты minimal API, которые привязывают данные формы, защищены, как только вы добавите app.UseAntiforgery().
Аутентификация через JWT bearer#
$ dotnet add package Microsoft.AspNetCore.Authentication.JwtBearerС внешним провайдером идентификации - Auth0, Microsoft Entra ID, Keycloak, Duende IdentityServer и подобными - вы направляете обработчик на издателя, и он скачивает ключи подписи из метаданных провайдера:
builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = "https://login.example.com/"; options.Audience = "orders-api"; options.MapInboundClaims = false; });MapInboundClaims = false сохраняет имена claims такими, какие они в токене (sub, email, role), вместо того чтобы переводить их в длинные URI ClaimTypes, - именно этого большинство ожидает, когда читает User.FindFirst("sub"). Если вы отключаете сопоставление, скажите обработчику, в каких claims лежат имя и роли, через TokenValidationParameters.NameClaimType и RoleClaimType.
Если токены выдаёт ваше собственное приложение, вы проверяете их ключом, который храните сами:
options.TokenValidationParameters = new TokenValidationParameters{ ValidIssuer = "https://api.example.com", ValidAudience = "orders-api", IssuerSigningKey = new SymmetricSecurityKey( Convert.FromBase64String(builder.Configuration["Jwt:SigningKey"]!)), ClockSkew = TimeSpan.FromSeconds(30)};ClockSkew по умолчанию равен пяти минутам, а значит, истёкший токен принимается ещё пять минут после его exp. Уменьшите его, когда будете доверять часам. Ключ подписи - такой же секрет, как пароль базы данных: переменная окружения или хранилище секретов, но никогда не appsettings.json в репозитории. Где ему место, разобрано в статье Конфигурация и секреты в ASP.NET Core.
Выдавать собственные JWT означает самому отвечать за refresh-токены, отзыв, ротацию ключей и хранение токенов на клиенте. Каждая из этих задач решаема, а все вместе они - причина, по которой для большинства небольших проектов совет звучит как «используйте cookies или провайдер идентификации». Если токены нужны, а запускать провайдер не хочется, следующий раздел предлагает средний путь.
ASP.NET Core Identity#
Identity - это база пользователей и логика вокруг неё: хеширование паролей, блокировка, подтверждение email, двухфакторные коды, внешние входы и роли. Она хранит пользователей через Entity Framework Core, так что работает с SQL Server, PostgreSQL, MySQL или чем угодно ещё, что поддерживает EF Core.
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));builder.Services .AddIdentityApiEndpoints<IdentityUser>() .AddEntityFrameworkStores<AppDbContext>();var app = builder.Build();app.MapGroup("/account").MapIdentityApi<IdentityUser>();AddIdentityApiEndpoints и MapIdentityApi, появившиеся в .NET 8, дают JSON API с /register, /login, /refresh, подтверждением email, сбросом пароля и управлением двухфакторной аутентификацией. Эндпоинт входа возвращает либо cookie (с ?useCookies=true), либо bearer-токен. Эти токены - не JWT: они непрозрачные, защищены системой data protection, описанной ниже, и прочитать их может только выдавшее их приложение. Для одного приложения и его собственного фронтенда это достоинство; когда один и тот же токен должны проверять несколько сервисов, это неподходящий инструмент.
Для приложений с серверным рендерингом классический путь - AddDefaultIdentity<IdentityUser>() со сгенерированным интерфейсом на Razor Pages.
Значения Identity по умолчанию стоит знать до выпуска:
| Опция | По умолчанию | Комментарий |
|---|---|---|
Password.RequiredLength | 6 | Поднимите; длина важнее классов символов |
Password.RequireDigit, RequireUppercase, RequireLowercase, RequireNonAlphanumeric | true | Многие команды ослабляют эти требования и поднимают длину |
Lockout.MaxFailedAccessAttempts | 5 | Действует, только если вход вызывается с lockoutOnFailure: true |
Lockout.DefaultLockoutTimeSpan | 5 минут | |
SignIn.RequireConfirmedEmail | false | Включите, как только сможете отправлять почту |
User.RequireUniqueEmail | false | Обычно нужно true |
Строку про блокировку стоит прочитать дважды. SignInManager.PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure: false), который использовали старые шаблоны, вообще не считает неудачные попытки, так что форму входа на нём можно перебирать бесконечно. Передавайте true и вдобавок поставьте ограничение частоты перед эндпоинтом входа.
Пароли хешируются через PBKDF2 классом PasswordHasher из Identity, с маркером формата, так что более строгие настройки в новых версиях перехешируют старые пароли при следующем успешном входе.
Ключи data protection: почему деплой разлогинивает всех#
Каждый cookie, который пишет обработчик аутентификации, каждый antiforgery-токен, каждый bearer-токен Identity и каждая ссылка для сброса пароля зашифрованы ключами из системы data protection ASP.NET Core. По умолчанию в Linux эти ключи генерируются при первом использовании и записываются в ~/.aspnet/DataProtection-Keys - внутри контейнера, в домашнем каталоге того пользователя, от имени которого запущено приложение. Если этот каталог не переживает пересборку или повторный деплой, ключи тоже не переживают. Новый деплой генерирует новые ключи, и всё, что было зашифровано старыми, разом становится нечитаемым.
Симптомы характерные: после деплоя разлогинены все пользователи, на формах, открытых до деплоя, появляется The antiforgery token could not be decrypted, а при запуске - предупреждения вроде этих:
warn: Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository[60] Storing keys in a directory '/root/.aspnet/DataProtection-Keys' that may not be persisted outside of the container. Protected data will be unavailable when container is destroyed.warn: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[35] No XML encryptor configured. Key may be persisted to storage in unencrypted form.Решение - положить ключи туда, где они переживут процесс. Самое простое место - база данных, которой приложение уже пользуется:
$ dotnet add package Microsoft.AspNetCore.DataProtection.EntityFrameworkCorepublic class AppDbContext(DbContextOptions<AppDbContext> options) : IdentityDbContext<IdentityUser>(options), IDataProtectionKeyContext{ public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();}builder.Services.AddDataProtection() .SetApplicationName("orders-web") .PersistKeysToDbContext<AppDbContext>();Добавьте миграцию для новой таблицы и задеплойте. Альтернативы - PersistKeysToStackExchangeRedis (пакет Microsoft.AspNetCore.DataProtection.StackExchangeRedis, который работает с Valkey, если этот сервер Valkey сохраняет данные на диск) или PersistKeysToFileSystem с каталогом, который, как вы точно знаете, переживает деплои. SetApplicationName важен, когда несколько экземпляров или приложений должны делить ключи: по умолчанию имя приложения выводится из пути к content root, так что две копии одного и того же приложения, задеплоенные по разным путям, не смогут прочитать cookies друг друга.
По умолчанию ключи ротируются каждые 90 дней, а старые сохраняются, чтобы существующие cookies по-прежнему расшифровывались. Для ключей в хранилище ProtectKeysWithCertificate шифрует их сертификатом X.509; без него любой, кто может прочитать таблицу ключей, может подделывать cookies, так что база, где они лежат, заслуживает той же заботы, что и сама таблица пользователей. Эта сторона разобрана в чек-листе безопасности баз данных.
Внешние входы, OpenID Connect и выход#
Если пользователи входят через уже имеющийся у них аккаунт - Google, Microsoft, GitHub или корпоративный single sign-on, - хранение паролей, сценарии сброса и двухфакторные запросы переходят к тем, для кого это основная работа. В ASP.NET Core это удалённая схема аутентификации поверх обработчика cookie: удалённая схема проводит обмен с провайдером, а схема cookie запоминает результат.
builder.Services .AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie() .AddOpenIdConnect(options => { options.Authority = "https://login.example.com/"; options.ClientId = builder.Configuration["Oidc:ClientId"]; options.ClientSecret = builder.Configuration["Oidc:ClientSecret"]; options.ResponseType = "code"; options.SaveTokens = false; options.MapInboundClaims = false; });Пакеты для конкретных провайдеров - Microsoft.AspNetCore.Authentication.Google, .MicrosoftAccount и другие - это тонкие обёртки с уже заполненными эндпоинтами. У каждой схемы есть путь коллбэка, на который провайдер перенаправляет обратно (/signin-oidc для OpenID Connect, /signin-google для Google), и именно этот URL, с https:// и вашим настоящим доменом, должен быть зарегистрирован у провайдера. За прокси, который терминирует TLS, приложение строит этот URL по запросу, который видит; без forwarded headers оно строит адрес http://, провайдер отклоняет его как незарегистрированный, и страница ошибки, которую вы видите, - страница провайдера, а не ваша.
SaveTokens = false не пускает access- и refresh-токены провайдера в auth cookie. Сохраняйте их, только если приложение вызывает API провайдера от имени пользователя, потому что они раздувают cookie - настолько, что при нескольких claims он разбивается на несколько cookie-частей.
Выход - это HttpContext.SignOutAsync() для схемы cookie плюс выход у провайдера, если вы хотите разлогинить пользователя и там. Поскольку auth cookie самодостаточен, удаление пользователя или смена его роли не влияет на уже выданный cookie: он остаётся действительным до истечения срока. Identity решает это через security stamp - значение, которое меняется при смене пароля или настроек безопасности и по умолчанию перепроверяется каждые 30 минут (SecurityStampValidatorOptions.ValidationInterval). Сократите интервал, если отзыв должен срабатывать быстрее, или проверяйте флаг «отключён» в собственном событии валидации cookie, чтобы эффект был немедленным. Двухфакторная аутентификация через приложение-аутентификатор встроена в Identity - UserManager генерирует и проверяет TOTP-коды и коды восстановления, - и её стоит включить для каждого аккаунта с административными правами.
Политики авторизации#
Аутентификация говорит, кто; политики решают, что можно. Роли работают и годятся для грубых правил:
builder.Services.AddAuthorizationBuilder() .AddPolicy("Admin", p => p.RequireRole("admin")) .AddPolicy("CanRefund", p => p.RequireClaim("permission", "refunds")) .SetFallbackPolicy(new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build());app.MapPost("/orders/{id}/refund", Refund).RequireAuthorization("CanRefund");app.MapGet("/health", () => "ok").AllowAnonymous();Fallback-политика - самая полезная строка здесь: она требует вошедшего пользователя на каждом эндпоинте, если тот явно не помечен [AllowAnonymous] или .AllowAnonymous(). Это переворачивает поведение по умолчанию с «забыл атрибут - значит, публично» на «забыл атрибут - значит, закрыто», а именно в эту сторону и должны проваливаться ошибки. Не забудьте пометить как анонимные страницу входа, статические ресурсы, которые вы отдаёте через эндпоинты, и health checks.
Политики, зависящие от ресурса, - «пользователи могут редактировать только свои заказы» - это авторизация на основе ресурсов, она делается через IAuthorizationService.AuthorizeAsync(User, order, "EditOrder") и обработчик, получающий заказ. Не кодируйте такие правила в ролях.
Решение проблем#
Вошёл, а потом сразу снова анонимный. Неправильный порядок middleware, cookie помечен как secure, а приложение считает запрос HTTP (не хватает forwarded headers), или SameSite=Strict отбрасывает cookie при межсайтовом редиректе обратно от провайдера входа.
`IDX10500: Signature validation failed` или `IDX10214: Audience validation failed`. Токен выдан для другой аудитории, или приложение проверяет его не тем ключом или не тем authority. Декодируйте токен локально и сравните iss и aud со своими настройками.
После каждого деплоя разлогинены все. Ключи data protection не сохраняются. См. выше.
`User.Identity.Name` равен null с JWT. Сопоставление claims выключено, а NameClaimType не указывает на claim, который используют ваши токены.
OAuth-коллбэк падает с ошибкой корреляции. Cookie корреляции не пережил обмен - обычно потому, что за прокси, терминирующим TLS, приложение сгенерировало redirect URI с http://. Сначала исправьте forwarded headers.
FAQ#
Безопасно ли хранить JWT в localStorage?
Это работает, и любой скрипт, выполняющийся на вашей странице, может его прочитать - так что одна XSS-уязвимость сливает все токены. Cookie с HttpOnly скрипты прочитать не могут. Для браузерного приложения на том же домене, что и его API, cookies - более безопасный вариант по умолчанию.
Нужна ли мне ASP.NET Core Identity?
Только если ваше приложение хранит собственных пользователей и пароли. Если пользователи входят через внешний провайдер или через уже работающую у вас систему single sign-on, одного обработчика cookie или JWT достаточно, а Identity добавит таблицы, которыми вы не будете пользоваться.
Сколько должен жить auth cookie?
Достаточно долго, чтобы людей это не раздражало, и достаточно коротко, чтобы украденный ноутбук не стал вечной сессией. Большинству сайтов подходят семь-четырнадцать дней со скользящим сроком; административным разделам нужен меньший срок жизни и двухфакторная аутентификация.
Почему ошибки antiforgery появляются только после деплоя?
Потому что токен был зашифрован ключом data protection, которого больше нет. Сохраняйте ключи в базе данных или другом хранилище, переживающем деплои, и ошибки прекратятся.
Могут ли два экземпляра моего приложения делить входы пользователей?
Да, если у них общие ключи data protection и одно и то же имя приложения. Храните ключи в общей базе данных или Valkey и вызывайте SetApplicationName с одинаковым значением на обоих.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.