RE:NODE

App hosting11 min read

ASP.NET Core authentication: cookies, JWT and Identity

Choose between cookie and JWT authentication in ASP.NET Core, set up Identity, and persist data protection keys so redeploys stop signing everyone out.

0 readers

For a website where the browser and the server are the same app, use cookie authentication. For an API called by mobile apps, other servers or a single-page app on a different domain, use bearer tokens - usually JWTs issued by an identity provider. ASP.NET Core Identity is the user store and password logic, not a third authentication scheme; it sits underneath either one. Whichever you pick, the thing that breaks most hosted apps is not the choice at all: it is data protection keys that live inside the container and vanish on redeploy, signing every user out and breaking every form. This post covers the choice, the setup of each, Identity's defaults, and how to persist those keys properly.

Authentication, authorisation and the middleware order#

Two words, two jobs. Authentication works out who the caller is, from a cookie, a token or a certificate, and builds a ClaimsPrincipal. Authorisation decides whether that principal may do the thing it is asking to do. ASP.NET Core keeps them separate, with a handler per scheme for the first and policies for the second.

csharp
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 adds the authentication and authorisation middleware for you when their services are registered, but writing them out keeps the order visible, and the order matters: authentication must run before authorisation, and both after routing so endpoint metadata such as [Authorize] is known. Forwarded headers come first when the app is behind a reverse proxy, otherwise the app believes every request arrived over plain HTTP and cookies marked secure, redirect URLs and OAuth callbacks all go wrong. ASP.NET Core behind a reverse proxy has the exact configuration.

Cookies or JWT: choosing by client#

ClientUseWhy
Server-rendered site (Razor Pages, MVC, Blazor Server)CookiesThe browser stores and sends them; nothing to manage in JavaScript
SPA served from the same domain as the APICookiesSame reasons, plus HttpOnly keeps the credential away from scripts
SPA on a different domainTokens, or a backend-for-frontend with cookiesCross-site cookies are increasingly blocked by browsers
Mobile or desktop appTokensNo cookie jar to rely on
Server-to-serverTokens (client credentials)No user, no browser

The argument that JWTs are "stateless and therefore better" is mostly about scale you do not have. A signed cookie is just as stateless - ASP.NET Core's auth cookie contains the encrypted claims, not a session ID - and it has two properties tokens in JavaScript do not: the browser handles storage and expiry, and an HttpOnly cookie cannot be read by an injected script. Tokens earn their place when the client is not a browser, or when a separate identity provider issues them for several APIs.

csharp
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;    });

The defaults are reasonable: ExpireTimeSpan is 14 days, SlidingExpiration is on (the cookie is reissued when more than half its lifetime has passed), the cookie is HttpOnly, and SameSite is Lax. Signing in is building a principal and handing it to the handler:

csharp
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 makes a session cookie that disappears when the browser closes; true gives it the expiry above.

Two things catch people. For an API endpoint, the cookie handler's default response to an unauthenticated request is a 302 redirect to the login page, which a JavaScript client receives as an HTML page with status 200. Override options.Events.OnRedirectToLogin to return 401 for API paths. And because the browser sends cookies automatically, cookie authentication needs antiforgery protection for state-changing requests: MVC and Razor Pages validate tokens on forms, and from .NET 8 minimal API endpoints that bind form data are protected once you add app.UseAntiforgery().

JWT bearer authentication#

bash
$ dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

With an external identity provider - Auth0, Microsoft Entra ID, Keycloak, Duende IdentityServer and similar - you point the handler at the issuer and it downloads the signing keys from the provider's metadata:

csharp
builder.Services    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)    .AddJwtBearer(options =>    {        options.Authority = "https://login.example.com/";        options.Audience = "orders-api";        options.MapInboundClaims = false;    });

MapInboundClaims = false keeps claim names as they are in the token (sub, email, role) instead of translating them to the long ClaimTypes URIs, which is what most people expect when they read User.FindFirst("sub"). If you turn mapping off, tell the handler which claims carry the name and roles through TokenValidationParameters.NameClaimType and RoleClaimType.

If your own app issues tokens, you validate them with a key you hold:

csharp
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 defaults to five minutes, which means an expired token is still accepted for five minutes after its exp. Lower it once you trust the clocks. The signing key is a secret like a database password - environment variable or secret store, never appsettings.json in the repository. ASP.NET Core configuration and secrets covers where it should live.

Issuing your own JWTs means owning refresh tokens, revocation, key rotation and token storage on the client. Each is solvable, and together they are why "use cookies, or use an identity provider" is the advice for most small projects. If you need tokens and do not want to run a provider, the next section has a middle path.

ASP.NET Core Identity#

Identity is the user database and the logic around it: password hashing, lockout, email confirmation, two-factor codes, external logins and roles. It stores users through Entity Framework Core, so it works with SQL Server, PostgreSQL, MySQL or anything else EF Core supports.

csharp
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 and MapIdentityApi, added in .NET 8, give a JSON API with /register, /login, /refresh, email confirmation, password reset and two-factor management. The login endpoint returns either a cookie (with ?useCookies=true) or a bearer token. Those tokens are not JWTs: they are opaque, protected by the data protection system described below, and only the app that issued them can read them. That is a feature for a single app and its own front end; it is the wrong tool when several services must validate the same token.

For server-rendered apps, AddDefaultIdentity<IdentityUser>() with the scaffolded Razor Pages UI is the classic route.

Identity's defaults are worth knowing before you ship:

OptionDefaultComment
Password.RequiredLength6Raise it; length beats character classes
Password.RequireDigit, RequireUppercase, RequireLowercase, RequireNonAlphanumerictrueMany teams relax these and raise the length
Lockout.MaxFailedAccessAttempts5Only applies when sign-in is called with lockoutOnFailure: true
Lockout.DefaultLockoutTimeSpan5 minutes
SignIn.RequireConfirmedEmailfalseTurn on once you can send email
User.RequireUniqueEmailfalseUsually you want true

The lockout row is the one to read twice. SignInManager.PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure: false) - which older templates used - does not count failures at all, so a login form built on it can be guessed at forever. Pass true, and put rate limiting in front of the login endpoint as well.

Passwords are hashed with PBKDF2 by Identity's PasswordHasher, with a format marker so stronger settings in newer versions rehash old passwords on the next successful login.

Data protection keys: why redeploys sign everyone out#

Every cookie the auth handler writes, every antiforgery token, every Identity bearer token and every password reset link is encrypted with keys from ASP.NET Core's data protection system. By default on Linux, those keys are generated on first use and written to ~/.aspnet/DataProtection-Keys - inside the container, under the home directory of whichever user runs the app. If that directory does not survive a rebuild or redeploy, the keys do not either. A new deploy generates new keys, and everything encrypted with the old ones becomes unreadable at once.

The symptoms are distinctive: every user signed out after a deploy, The antiforgery token could not be decrypted on forms opened before the deploy, and warnings like this at startup:

code
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.

The fix is to put the keys somewhere that outlives the process. The database the app already uses is the simplest place:

bash
$ dotnet add package Microsoft.AspNetCore.DataProtection.EntityFrameworkCore
csharp
public class AppDbContext(DbContextOptions<AppDbContext> options)    : IdentityDbContext<IdentityUser>(options), IDataProtectionKeyContext{    public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();}builder.Services.AddDataProtection()    .SetApplicationName("orders-web")    .PersistKeysToDbContext<AppDbContext>();

Add a migration for the new table and deploy. Alternatives are PersistKeysToStackExchangeRedis (the Microsoft.AspNetCore.DataProtection.StackExchangeRedis package, which works against Valkey, as long as that Valkey server persists to disk) or PersistKeysToFileSystem with a directory you know survives deploys. SetApplicationName matters when several instances or apps must share keys: by default the application name is derived from the content root path, so two copies of the same app deployed to different paths cannot read each other's cookies.

Keys rotate every 90 days by default, and old keys are kept so existing cookies still decrypt. For keys at rest, ProtectKeysWithCertificate encrypts them with an X.509 certificate; without it, anyone who can read the key table can forge cookies, so the database holding them deserves the same care as the user table itself. Database security checklist covers that side.

External logins, OpenID Connect and signing out#

Letting users sign in with an account they already have - Google, Microsoft, GitHub, or your company's single sign-on - moves password storage, reset flows and two-factor prompts to somebody whose full-time job it is. In ASP.NET Core this is a remote authentication scheme layered on top of the cookie handler: the remote scheme handles the round trip to the provider, and the cookie scheme remembers the result.

csharp
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;    });

The provider-specific packages - Microsoft.AspNetCore.Authentication.Google, .MicrosoftAccount and others - are thin wrappers with the endpoints filled in. Each scheme has a callback path that the provider redirects back to (/signin-oidc for OpenID Connect, /signin-google for Google), and that exact URL, with https:// and your real domain, must be registered with the provider. Behind a TLS-terminating proxy, the app builds that URL from the request it sees; without forwarded headers it builds an http:// address, the provider rejects it as unregistered, and the error page you see is the provider's, not yours.

SaveTokens = false keeps the provider's access and refresh tokens out of the auth cookie. Store them only if the app calls the provider's APIs on the user's behalf, because they make the cookie large - large enough, with several claims, that it is split into chunked cookies.

Signing out is HttpContext.SignOutAsync() for the cookie scheme, plus a sign-out at the provider if you want the user logged out there too. Because the auth cookie is self-contained, deleting a user or changing their role does not affect a cookie already issued: it remains valid until it expires. Identity handles this with a security stamp, a value that changes when the password or security settings change and is re-checked every 30 minutes by default (SecurityStampValidatorOptions.ValidationInterval). Shorten the interval if revocation needs to bite faster, or check a "disabled" flag in your own cookie validation event for immediate effect. Two-factor authentication through an authenticator app is built into Identity - UserManager generates and verifies TOTP codes and recovery codes - and is worth switching on for every account with administrative rights.

Authorisation policies#

Authentication tells you who; policies decide what. Roles work and are fine for coarse rules:

csharp
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();

The fallback policy is the most useful line here: it makes every endpoint require a signed-in user unless it is explicitly marked [AllowAnonymous] or .AllowAnonymous(). That reverses the default from "forgot the attribute means public" to "forgot the attribute means locked", which is the direction you want mistakes to fail in. Remember to mark the login page, static assets you serve through endpoints, and health checks as anonymous.

Policies that depend on the resource - "users may edit only their own orders" - are resource-based authorisation, done with IAuthorizationService.AuthorizeAsync(User, order, "EditOrder") and a handler that receives the order. Do not encode that kind of rule in roles.

Troubleshooting#

Signed in, then immediately anonymous again. The middleware order is wrong, the cookie is marked secure but the app thinks the request is HTTP (forwarded headers missing), or SameSite=Strict is dropping the cookie on a cross-site redirect back from a login provider.

`IDX10500: Signature validation failed` or `IDX10214: Audience validation failed`. The token was issued for a different audience, or the app is validating with the wrong key or authority. Decode the token locally and compare iss and aud with your settings.

Everyone logged out after each deploy. Data protection keys are not persisted. See above.

`User.Identity.Name` is null with JWTs. Claim mapping is off and NameClaimType is not set to the claim your tokens use.

OAuth callback fails with a correlation error. The correlation cookie did not survive the round trip - commonly because the app generated an http:// redirect URI behind a TLS-terminating proxy. Fix forwarded headers first.

FAQ#

Is it safe to store a JWT in localStorage?

It works, and any script that runs on your page can read it - so one cross-site scripting bug leaks every token. An HttpOnly cookie cannot be read by scripts. For a browser app on the same domain as its API, cookies are the safer default.

Do I need ASP.NET Core Identity?

Only if your app stores its own users and passwords. If users sign in with an external provider, or through a single sign-on system you already run, the cookie or JWT handler alone is enough and Identity adds tables you will not use.

Long enough that people are not annoyed, short enough that a stolen laptop is not a permanent session. Seven to fourteen days with sliding expiration suits most sites; admin areas deserve shorter lifetimes and two-factor authentication.

Why do I get antiforgery errors only after deploying?

Because the token was encrypted with a data protection key that no longer exists. Persist the keys to the database or another store that survives deploys and the errors stop.

Can two instances of my app share sign-ins?

Yes, if they share data protection keys and the same application name. Store the keys in a shared database or Valkey and call SetApplicationName with the same value on both.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000