Authentication and Authorization in ASP.NET Core
Add ASP.NET Core authentication and authorization to a Web API: JWT bearer and cookies, claims, roles, policies, and the [Authorize] attribute.
ClaimsPrincipal from a token or cookie, then authorization evaluates it against attributes and policies. The working stack for an API is JWT bearer validation plus roles or policies behind [Authorize], with the token issuance outsourced to an identity provider.The bug report that brings teams here reads something like: “User A can see User B’s tasks.” The code review finds a controller with no [Authorize] attribute and a service that trusts the userId from the query string, because “the gateway handles auth” (it did not), or an if statement about admin rights in one of fourteen actions, copied inconsistently into three of them. Security that lives in scattered conditionals is not a policy; it is a countdown.
ASP.NET Core authentication is the framework’s mechanism for turning credentials (a bearer token, a cookie, an OIDC login result) into an identity attached to the request. Authorization is the separate mechanism that decides what that identity may do. The authentication overview and the authorization introduction are the official references. This page is the practical tour: JWT bearer for APIs, cookies for web apps, claims and policies for decisions.
The examples assume .NET SDK 10.0 with ASP.NET Core 10 and continue the TaskApi from the Web API guide. This page adds authentication first, then authorization. Middleware placement matters here, so the pipeline order guide is the prerequisite on a skim.
Many security reviews later, I am convinced: framework auth is unglamorous and correct. Almost every bad security decision I have seen came from someone adding their own layer because the built-in one looked too simple.
Authentication vs authorization: two different questions
Authentication answers “who are you” and runs first; authorization answers “may you do this” and runs per endpoint. The protocol distinction shows up directly in status codes: 401 Unauthorized means the identity is missing or invalid (despite the confusing name, it is an authentication failure). 403 Forbidden means the identity is known and not allowed. The middleware order in the pipeline guide matters here: UseAuthentication before UseAuthorization, because the decision needs the identity.
REQUEST: Authorization: Bearer eyJhbGciOi...
|
v
UseAuthentication: validate the token, build ClaimsPrincipal
|
v
UseAuthorization: [Authorize] attributes and policies evaluate it
|
v
Endpoint: User.Claims available; 401 if anonymous, 403 if denied
ASP.NET Core authentication with JWT bearer
The JWT bearer handler validates the token on each request and builds the user’s identity from its claims. The complete setup for the TaskApi:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Convert.FromBase64String(builder.Configuration["Jwt:SigningKey"]!))
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
The four Validate* lines are the entire security posture of the token check, and none of them is optional in production. The signing key is a secret: in development it lives in dotnet user-secrets. In production it comes from an environment variable or a key vault, never from a committed appsettings file. The JWT bearer configuration guide documents the full set of options.
What is actually inside a JWT
A JSON Web Token is three base64url parts joined by dots. All three are readable by anyone who holds the token; only the signature makes it trustworthy:
eyJhbGciOiJIUzI1NiJ9 header: { "alg": "HS256" }
.
eyJzdWIiOiJpbXJhbiIs payload: { "sub": "imran",
ICJyb2xlIjoiYWRtaW4iLA== "role": "admin",
ImV4cCI6MTc5xNTM0N30 "exp": 1790000000 }
.
SflKxwRJSMeKKF2QT4f... signature: HMAC over header + payload
with the signing key
Two properties drive every JWT decision. The payload is not encrypted, so a claim that should be secret (a password, an API key) does not belong in it. The signature is only as strong as its key handling, which is why the signing key stays in secrets management, rotates, and never ships in source.
Claims, roles, and policies
Once authentication runs, the request carries a ClaimsPrincipal. That is an identity built from the token’s claims. Claims are name-value pairs like sub (the user id), email, or role. Authorization is decision-making over those claims, and the framework offers two levels:
Roles check a role claim against a string, fastest to write:
[Authorize(Roles = "admin")]
public IActionResult DeleteAll(Guid projectId) => /* ... */;
Policies express real rules. A policy pairs a requirement with a handler, and the handler runs arbitrary logic against the claims:
public sealed class ProjectMemberRequirement : IAuthorizationRequirement;
public sealed class ProjectMemberHandler
: AuthorizationHandler<ProjectMemberRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ProjectMemberRequirement requirement)
{
var projectId = /* from route or context resource */;
if (context.User.HasClaim("project", projectId.ToString()))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ProjectMember", policy =>
policy.RequireAuthenticatedUser()
.AddRequirements(new ProjectMemberRequirement()));
});
builder.Services.AddScoped<IAuthorizationHandler, ProjectMemberHandler>();
Handlers resolve from the container, so a handler can take a service (a project-membership lookup backed by the database, for example). The registration side is covered by the dependency injection guide. Endpoints then declare the rule by name:
[Authorize(Policy = "ProjectMember")]
public IActionResult GetTasks(Guid projectId) => /* ... */;
| Question | Roles | Policies |
|---|---|---|
| Best for | Broad, stable categories (admin, support) | Rules with logic, data, or parameters |
| Where the rule lives | The string on the attribute | A requirement plus handler class |
| Needs services to decide? | No | Yes, injected into the handler |
| Testable separately? | Only through requests | Yes, unit test the handler |
The rule of thumb: use roles for coarse access that almost never changes, and policies for decisions with logic in them. Checks that depend on the request body belong in domain services, because policy handlers run before model binding fills in the action's world.
Protecting endpoints
On the endpoint side, three tools cover every shape. [Authorize] on a controller class protects every action, [Authorize(Policy = "...")] adds a rule, and [AllowAnonymous] punches holes where public access is intended:
[ApiController]
[Route("api/[controller]")]
[Authorize] // whole controller: any authenticated user
public sealed class TasksController : ControllerBase
{
[HttpGet("public-summary")]
[AllowAnonymous] // one public action
public IActionResult PublicSummary() => /* ... */;
[HttpDelete("{id:guid}")]
[Authorize(Policy = "ProjectMember")] // stricter rule
public IActionResult Delete(Guid id) => /* ... */;
}
Reading the caller inside an action is plain framework plumbing: User.Identity?.Name, User.FindFirst("email")?.Value, and User.HasClaim(...). One pattern to avoid: authorization decisions as if (User.IsInRole(...)) return Forbid() sprinkled through actions. It duplicates policy logic and drifts from the real checks, which belong in attributes and handlers.
Cookie authentication at orientation level
Server-rendered apps and browser-facing MVC typically use cookie authentication instead: AddCookie() sets an authenticated, signed, http-only cookie after a login page. The browser replays it on every request. The table gives the decision shape:
| Aspect | JWT bearer (APIs) | Cookie (web apps) |
|---|---|---|
| Token lives | Authorization header, client-managed | http-only cookie, browser-managed |
| Cross-service calls | Natural: same token passes along | Awkward: cookie belongs to a domain |
| Browser XSS exposure of the token | Token readable by scripts if stored in JS-accessible storage | http-only cookie unreadable by scripts |
| Revocation | Short lifetimes plus refresh, or server-side blocklist | Delete/invalidate the cookie server-side |
External identity providers at orientation level
This page validates tokens; it deliberately does not issue them. Password storage, login flows, token issuance, refresh handling, and user management belong to an identity provider. Microsoft Entra ID, Keycloak, Auth0, and Duende IdentityServer are the common choices. Your API configures AddJwtBearer with the provider's issuer, audience, and keys (often via its OpenID Connect discovery document). The provider does the rest. For browser apps, AddOpenIdConnect walks the OIDC login flow and lands on the cookie handler. Outsourcing issuance is not laziness. It is the correct division of labor, the same reason you do not write your own TLS.
How real systems do this
- Short-lived tokens plus refresh. Access tokens live minutes, not days. A refresh flow owned by the identity provider mints new ones. This means a stolen token ages out quickly, and revocation does not depend on the resource API.
- Auth failures logged with correlation ids. 401s and 403s are logged as structured events with the request id. As a result, "user A saw user B's data" incidents leave a trail to follow rather than a mystery to reconstruct.
- Auth under integration test. Tests exercise the real pipeline with tokens issued for the test identity, plus requests with no token and requests with a valid token for the wrong user. The WebApplicationFactory guide covers wiring that.
A security review I remember well found two classic problems in one service. First, a signing key was committed to the repository, so anyone with repo access could mint valid tokens. Second, a token validation block had ValidateLifetime set to false for local development, and nobody turned it back on. Neither was visible from the endpoint behavior; both were one-file findings. What stuck with me: authentication configuration is a security surface. Review and test it with the same rigor as the code that handles money.
Decision framework: choosing the pieces
- Who is the client? Machine-to-machine and single-page apps calling an API: JWT bearer. Browser-rendered pages with logins: cookies, possibly via OIDC. Same product, both channels: both handlers, authenticated once.
- Coarse category or a real rule? Stable categories like admin: roles. Decisions involving data or logic: policies with handlers.
- Does the check depend on request data? Policy handlers work for route and header data. Checks that depend on the bound request body belong in the action's service, calling the authorization service explicitly.
- Own the users, or outsource? Unless storing credentials and login flows is your product, an identity provider issues; you validate.
- Public by default or private by default? APIs default to
[Authorize]on the controller with explicit[AllowAnonymous]exceptions. The inverse lets one forgotten attribute ship a hole.
When NOT to reach for the machinery
- Health checks and metrics endpoints. They exist for orchestrators to probe. Gate them at the network layer or leave them anonymous on purpose. This is because an auth failure would look like a crash loop to a load balancer.
- Internal-only APIs behind a locked network. Still add authorization, but the authentication side may reasonably be handled by the platform's service-to-service mechanism; do not duplicate an entire identity system inside the perimeter.
- Token minting inside your app. If you feel the pull to issue your own tokens, that is the identity provider's job. A resource API validates, an IdP issues, and confusing the two roles is where hand-rolled crypto starts.
Common mistakes
- UseAuthorization before UseAuthentication. Authorization then evaluates an empty identity and everything looks anonymous; the order is fixed and covered above.
- Blank TokenValidationParameters. Disabling issuer, audience, or lifetime validation makes any well-formed token pass. The four Validate flags are the security check, not ceremony.
- Signing keys in committed files. A key in source control means anyone with read access can mint identities; user-secrets in development, key vault or environment secrets in production.
- Secrets inside JWT payloads. The payload is base64, readable by the holder. Tokens carry identifiers and coarse authorization data, never credentials.
- Role checks scattered as if-statements. Authorization in actions drifts. The attribute and policy mechanism exists so every rule has one home and tests can verify it.
- 403 for anonymous requests. A missing identity deserves 401 with the challenge (the WWW-Authenticate header). 403 tells a client its identity is valid but banned, which is a different bug report.
Key takeaways
- Authentication identifies the caller and builds a ClaimsPrincipal; authorization decides access; 401 means "unidentified", 403 means "not allowed".
- JWT bearer validation checks issuer, audience, lifetime, and signature. None of the four flags is optional in production, and the signing key lives in secrets management.
- Claims are the vocabulary: roles for stable categories, policies with injected handlers for real rules,
[AllowAnonymous]for deliberate exceptions. - Protect controllers wholesale with
[Authorize]and open specific actions, not the reverse. - Cookies suit browser apps; JWTs suit APIs and service-to-service calls; the table above is the decision record.
- Token issuance, passwords, and refresh flows belong to an identity provider; the resource API only validates what an IdP issues.
FAQ
What is the difference between authentication and authorization in ASP.NET Core?
Authentication identifies the caller, building a ClaimsPrincipal from a token or cookie, and answers with 401 when identity is missing or invalid. Authorization decides what that identity may access, evaluated per endpoint through the [Authorize] attribute, roles, and policies, and answers with 403 when access is denied. The framework keeps them separate: UseAuthentication runs before UseAuthorization in the pipeline.
How does JWT bearer authentication work in ASP.NET Core?
The AddJwtBearer handler validates the token on every request: it checks the signature against the configured signing key, confirms the issuer and audience match, verifies the expiry, and builds a ClaimsPrincipal from the claims. The endpoint then authorizes that principal. Your code never parses the token; it declares what a valid one looks like and uses User and claims afterward.
What are claims in ASP.NET Core authorization?
Claims are name-value pairs attached to an identity, such as sub for the user id, email, or role, usually sourced from the token payload. Authorization decisions evaluate claims: role checks compare a role claim, and policies can test any claim or call a handler that inspects them. Claims are the shared vocabulary between the identity provider and your authorization rules.
Should I use roles or policies in ASP.NET Core?
Roles suit broad, stable categories like admin, checked with a simple string comparison. Policies suit rules with logic, parameters, or data access. This is because a requirement plus a handler class makes the rule explicit, testable, and injectable with services. Use roles for coarse access and policies the moment a rule needs to think.
Should the API issue its own JWTs?
No, unless issuing tokens is your product. An identity provider such as Microsoft Entra ID, Keycloak, or Auth0 owns users, passwords, login flows, and token issuance; your API configures AddJwtBearer to validate tokens that provider issues. Issuing tokens yourself pulls in credential storage, key management, and refresh flows that an IdP already handles and audits.
Authenticate the caller, authorize the action
The framework's split is the whole design. One mechanism establishes identity and another evaluates it. Both are configuration plus small classes, not hand-written middleware. Validate tokens with all four flags on and keep secrets in secret stores. Put rules in roles and policies where they are reviewable, then test the whole pipeline with real requests. Next in this area, error handling and Problem Details covers what a request gets back when something goes wrong. Every guide in the series lives on the .NET series index. The rule of thumb: the identity provider signs, the framework validates, and your code never invents crypto.
Last updated on 31 October 2026