Overview
JWT bearer authentication is a common way to secure ASP.NET Core Web APIs. A client sends an access token in the HTTP Authorization header, and the API validates that token before allowing the request to continue.
A typical request looks like this:
GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
The token is usually issued by a trusted identity provider, such as Microsoft Entra ID, Auth0, Okta, IdentityServer, or another OpenID Connect/OAuth 2.0 provider. The API does not normally sign in users directly. Instead, it validates the access token and builds a ClaimsPrincipal from the token claims.
This topic matters because modern C# APIs commonly use token-based security for single-page applications, mobile apps, microservices, backend-for-frontend services, and service-to-service communication. Developers must understand the difference between validating a token and authorizing access to a specific endpoint or resource.
JWT bearer authentication answers: Is this token valid, and who or what does it represent?
Authorization answers: Does this identity have the required role, claim, scope, permission, or resource access?
Claims, scopes, roles, and policies are central to this process:
- Claims describe the subject or client.
- Scopes usually represent delegated permissions granted to a client acting on behalf of a user.
- Roles often represent app roles, user roles, or application permissions.
- Policies define reusable authorization rules in ASP.NET Core.
- Requirements and handlers support custom authorization logic.
This topic is important for interviews because it tests practical API security knowledge. A strong candidate should know how JWT bearer authentication works, what token validation should check, how claims are used, why [Authorize] alone may not be enough, how scopes differ from roles, how to implement policy-based authorization, and how to avoid common security mistakes.
Core Concepts
What JWT Bearer Authentication Is
JWT stands for JSON Web Token. A JWT is a compact token format that can carry claims about a user, client application, or service.
A JWT typically has three parts:
header.payload.signature
Bearer authentication means the caller presents a bearer token. Whoever possesses a valid bearer token can use it, so bearer tokens must be protected carefully.
In ASP.NET Core, JWT bearer authentication is usually configured with AddAuthentication and AddJwtBearer.
Example:
using Microsoft.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://login.example.com";
options.Audience = "orders-api";
});
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
This configuration tells ASP.NET Core to validate bearer tokens using the JWT bearer handler.
Authentication vs Authorization in JWT-Based APIs
Authentication verifies the token and creates an identity.
Authorization checks whether the authenticated identity can access something.
Example:
[Authorize]
[HttpGet("profile")]
public IActionResult GetProfile()
{
return Ok();
}
[Authorize] requires an authenticated caller. But it does not automatically mean the caller has the correct scope, role, tenant, or resource ownership.
More specific authorization:
[Authorize(Policy = "Orders.Read")]
[HttpGet("orders")]
public IActionResult GetOrders()
{
return Ok();
}
In this case, the policy can check whether the token contains the required permission or scope.
A common interview point is that JWT validation is necessary but not sufficient. The API must also verify the token has the right claims for the requested operation.
Access Tokens vs ID Tokens
In API security, the API should validate access tokens, not ID tokens.
An access token is intended for an API. It contains information such as audience, issuer, expiration, subject, scopes, roles, and other claims.
An ID token is intended for the client application. It tells the client that the user authenticated successfully.
A common mistake is sending an ID token to an API and treating it as an access token. APIs should validate that the token audience is meant for that API.
Token Validation
A production API should fully validate incoming access tokens.
Important validation checks include:
Example:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://login.example.com";
options.Audience = "api://orders-api";
options.TokenValidationParameters.ValidateIssuer = true;
options.TokenValidationParameters.ValidateAudience = true;
options.TokenValidationParameters.ValidateLifetime = true;
});
Do not disable validation in production.
Bad example:
// Do not do this in production.
options.TokenValidationParameters.ValidateIssuer = false;
options.TokenValidationParameters.ValidateAudience = false;
options.TokenValidationParameters.ValidateLifetime = false;
Disabling validation can allow tokens issued for another API, from another issuer, or with expired lifetimes to be accepted incorrectly.
Authorization Header and Bearer Tokens
JWT bearer tokens are usually sent in the Authorization header.
Authorization: Bearer <access_token>
ASP.NET Core's JWT bearer handler reads this header, extracts the token, validates it, and creates HttpContext.User.
Controller example:
[Authorize]
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpGet("me")]
public IActionResult GetCurrentUser()
{
var subject = User.FindFirst("sub")?.Value;
var name = User.Identity?.Name;
return Ok(new
{
Subject = subject,
Name = name
});
}
}
If the token is missing or invalid, the API typically returns 401 Unauthorized.
If the token is valid but the caller does not meet authorization requirements, the API typically returns 403 Forbidden.
ClaimsPrincipal, ClaimsIdentity, and Claims
After token validation, ASP.NET Core represents the caller as a ClaimsPrincipal.
Important types:
Example:
[Authorize]
[HttpGet("claims")]
public IActionResult GetClaims()
{
var claims = User.Claims.Select(claim => new
{
claim.Type,
claim.Value
});
return Ok(claims);
}
Common JWT claims include:
A claim says something about the caller. It does not automatically grant access unless your authorization logic uses it.
Claims Are Not Always Permissions
A claim is a statement about the subject. It is not always a permission.
Example:
email: [email protected]
department: finance
tenant_id: tenant-001
These claims describe the caller. They do not automatically mean the user can access all finance data or all tenant data.
A permission-like claim might look like this:
permission: orders.read
permission: orders.write
A scope-like claim might look like this:
scp: orders.read orders.write
A role-like claim might look like this:
"roles": [
"Orders.Admin"
]
Good authorization logic should be explicit about which claims are used for access decisions.
Scopes
A scope represents a permission granted to a client application, commonly for delegated access on behalf of a signed-in user.
Example delegated scope:
orders.read
A token may contain scopes like this:
{
"sub": "user-123",
"aud": "api://orders-api",
"scp": "orders.read orders.write"
}
The scp claim is commonly used by Microsoft identity platform for delegated permissions. Some providers use scope instead. The value is often a space-separated list of scopes.
In an API, you should verify that the token contains the scope required by the endpoint.
Example:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Orders.Read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scp", "orders.read");
});
});
However, this simple RequireClaim example only works when the claim value exactly matches one of the expected values. Many scope claims contain space-separated values, so production code often needs a custom policy requirement or helper that splits the scope string.
Scope Claim with Space-Separated Values
Many identity providers put multiple scopes in one claim value.
Example:
{
"scp": "orders.read orders.write customers.read"
}
A basic RequireClaim("scp", "orders.read") may not match if the entire claim value is "orders.read orders.write customers.read".
A safer custom extension can split the scope value.
Example:
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
public static class AuthorizationPolicyBuilderExtensions
{
public static AuthorizationPolicyBuilder RequireScope(
this AuthorizationPolicyBuilder builder,
string requiredScope)
{
return builder.RequireAssertion(context =>
{
var scopeClaims = context.User.FindAll("scope")
.Concat(context.User.FindAll("scp"));
return scopeClaims
.SelectMany(claim => claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Contains(requiredScope, StringComparer.Ordinal);
});
}
}
Policy registration:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Orders.Read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireScope("orders.read");
});
});
Usage:
[Authorize(Policy = "Orders.Read")]
[HttpGet("orders")]
public IActionResult GetOrders()
{
return Ok();
}
This pattern is interview-relevant because it shows that the developer understands token claim shape, not just [Authorize].
Roles and App Roles
Roles are often used for broad access control.
Example:
{
"roles": [
"Admin",
"Orders.Manager"
]
}
ASP.NET Core can use roles with [Authorize(Roles = "...")].
Example:
[Authorize(Roles = "Admin")]
[HttpDelete("orders/{id:int}")]
public IActionResult DeleteOrder(int id)
{
return NoContent();
}
Multiple roles can be allowed:
[Authorize(Roles = "Admin,Orders.Manager")]
public IActionResult ApproveOrder(int id)
{
return Ok();
}
This means the user must be in either role.
Roles are simple, but large systems can suffer from role explosion. For fine-grained access, policies and permissions are usually better.
Scopes vs Roles
Scopes and roles are both used in authorization, but they model different things.
In Microsoft Entra-style tokens:
- Delegated user tokens often contain scopes in
scp. - App-only/client-credential tokens often contain app permissions in
roles. - User role assignments can also appear in
roles.
Practical interpretation:
- Use scopes to check whether the client was granted permission to call the API on behalf of a user.
- Use roles or app roles for broad role checks or daemon/service access.
- Use policy-based authorization to express the actual access rule clearly.
- Use resource-based authorization when the decision depends on the specific data being accessed.
Delegated Permissions vs Application Permissions
Delegated permissions are used when an app calls an API on behalf of a signed-in user.
Example:
User signs in -> SPA gets access token -> SPA calls API -> API sees user and scopes
The token might contain:
{
"sub": "user-123",
"scp": "orders.read orders.create"
}
Application permissions are used when an app calls an API as itself, without a signed-in user.
Example:
Background service -> gets app-only token -> calls API
The token might contain:
{
"client_id": "service-client-123",
"roles": [
"Orders.Import"
]
}
A production API should understand which token type each endpoint supports.
For example:
- A user endpoint may require
orders.readscope. - A background import endpoint may require
Orders.Importapp role. - Some endpoints may support both, but the policy should make that explicit.
Policy-Based Authorization
Policy-based authorization lets you define named rules and apply them to endpoints.
Example:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Orders.Read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireScope("orders.read");
});
options.AddPolicy("Orders.Delete", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("Orders.Admin");
});
});
Controller usage:
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[Authorize(Policy = "Orders.Read")]
[HttpGet]
public IActionResult GetOrders()
{
return Ok();
}
[Authorize(Policy = "Orders.Delete")]
[HttpDelete("{id:int}")]
public IActionResult DeleteOrder(int id)
{
return NoContent();
}
}
Minimal API usage:
app.MapGet("/api/orders", () => Results.Ok())
.RequireAuthorization("Orders.Read");
app.MapDelete("/api/orders/{id:int}", (int id) => Results.NoContent())
.RequireAuthorization("Orders.Delete");
Policies are useful because they centralize authorization rules and make endpoint intent clearer.
Requirements and Authorization Handlers
A policy can contain one or more requirements. Requirements can be evaluated by authorization handlers.
Example requirement:
using Microsoft.AspNetCore.Authorization;
public sealed class ScopeRequirement : IAuthorizationRequirement
{
public ScopeRequirement(string scope)
{
Scope = scope;
}
public string Scope { get; }
}
Example handler:
using Microsoft.AspNetCore.Authorization;
public sealed class ScopeRequirementHandler
: AuthorizationHandler<ScopeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ScopeRequirement requirement)
{
var scopes = context.User.FindAll("scp")
.Concat(context.User.FindAll("scope"))
.SelectMany(claim => claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
if (scopes.Contains(requirement.Scope, StringComparer.Ordinal))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Register the handler and policy:
builder.Services.AddSingleton<IAuthorizationHandler, ScopeRequirementHandler>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Orders.Read", policy =>
{
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new ScopeRequirement("orders.read"));
});
});
This approach is more maintainable than repeating manual claim parsing in every controller.
Multiple Requirements Are AND-Based
When a policy has multiple requirements, all requirements must pass.
Example:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Orders.Approve", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("Manager");
policy.RequireClaim("department", "sales");
});
});
This means:
- The user must be authenticated.
- The user must be in the
Managerrole. - The user must have
department = sales.
All conditions must succeed.
If you need OR behavior, you can use:
- Multiple accepted values in a single requirement.
RequireAssertion.- Multiple handlers for the same requirement.
- A custom authorization handler.
Example OR-style policy using RequireAssertion:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Orders.Read", policy =>
{
policy.RequireAssertion(context =>
HasScope(context.User, "orders.read") ||
context.User.IsInRole("Orders.Admin"));
});
});
static bool HasScope(ClaimsPrincipal user, string requiredScope)
{
return user.FindAll("scp")
.Concat(user.FindAll("scope"))
.SelectMany(claim => claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Contains(requiredScope, StringComparer.Ordinal);
}
Resource-Based Authorization
Sometimes authorization depends on the specific resource being accessed.
Examples:
- User can view only their own order.
- User can access only their tenant's data.
- Manager can approve only orders below a certain amount.
- Support agent can access only assigned tickets.
Endpoint-level policy checks may not be enough because the resource must be loaded first.
Example:
[Authorize]
[HttpGet("{id:int}")]
public async Task<IActionResult> GetOrder(
int id,
IAuthorizationService authorizationService,
CancellationToken cancellationToken)
{
var order = await _orderRepository.GetByIdAsync(id, cancellationToken);
if (order is null)
{
return NotFound();
}
var result = await authorizationService.AuthorizeAsync(
User,
order,
"CanReadOrder");
if (!result.Succeeded)
{
return Forbid();
}
return Ok(order);
}
This pattern prevents a common security bug: authenticated users accessing records they do not own.
Middleware Order
Authentication and authorization middleware must be placed correctly.
Typical order:
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
UseAuthentication() validates the token and sets HttpContext.User.
UseAuthorization() evaluates endpoint authorization requirements using HttpContext.User.
Wrong order can cause confusing behavior because authorization may run before the user has been authenticated.
401 Unauthorized vs 403 Forbidden
In JWT-secured APIs, correct status codes matter.
Examples:
Challenge() usually maps to authentication failure.
return Challenge();
Forbid() usually maps to authorization failure.
return Forbid();
Claims Mapping and Role Claim Types
JWT claim names do not always match ASP.NET Core's default claim type expectations.
Some identity providers emit:
{
"roles": ["Admin"]
}
Others may emit:
{
"role": "Admin"
}
ASP.NET Core role authorization depends on the configured role claim type.
Example:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://login.example.com";
options.Audience = "api://orders-api";
options.TokenValidationParameters.RoleClaimType = "roles";
options.TokenValidationParameters.NameClaimType = "name";
});
If role authorization does not work, check:
- The token actually contains the role claim.
- The role claim type matches the configured
RoleClaimType. - The role name casing matches.
- The endpoint uses the correct authentication scheme.
- The token audience is correct.
- The token is an access token, not an ID token.
Case Sensitivity
Claim values should be treated consistently.
For example:
Admin
admin
These should not be assumed to be the same.
Best practice:
- Use consistent casing for role names.
- Use consistent casing for scope names.
- Use constants for policy names and permission names.
- Avoid mixing
Orders.Read,orders.read, andorders:readwithout a clear convention.
Example constants:
public static class Policies
{
public const string OrdersRead = "Orders.Read";
public const string OrdersWrite = "Orders.Write";
}
public static class Scopes
{
public const string OrdersRead = "orders.read";
public const string OrdersWrite = "orders.write";
}
Avoid Putting Too Much Authorization Data in Tokens
JWTs are often self-contained, but they should not become large permission databases.
Problems with large tokens:
- Larger HTTP headers.
- More network overhead.
- Header size limits.
- Stale permissions until token expiration.
- Harder permission revocation.
- More sensitive data exposed to clients.
Good practice:
- Keep tokens focused on identity and coarse permission information.
- Use short-lived access tokens.
- Use resource-based checks for sensitive data.
- Query the database when authorization depends on live state.
- Avoid putting confidential business data in tokens.
Token Revocation and Expiration
JWT access tokens are often valid until they expire. Because they are self-contained, revocation can be harder than with server-side session storage.
Important design considerations:
- Use short access token lifetimes.
- Use refresh tokens carefully.
- Re-check sensitive permissions server-side when needed.
- Consider token versioning or security stamps for high-risk scenarios.
- Do not rely only on logout to invalidate already-issued access tokens unless the system supports revocation.
- For critical operations, check current database permissions.
Example:
[Authorize(Policy = "Orders.Approve")]
[HttpPost("{id:int}/approve")]
public async Task<IActionResult> ApproveOrder(int id)
{
// Even if token has a permission claim,
// still load the order and enforce current business rules.
return Ok();
}
Scope-Based Authorization Without Microsoft.Identity.Web
Some projects do not use Microsoft.Identity.Web. They can still implement scope checks using standard ASP.NET Core policies.
Example:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Customers.Read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context =>
{
var scopes = context.User.FindAll("scp")
.Concat(context.User.FindAll("scope"))
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
return scopes.Contains("customers.read", StringComparer.Ordinal);
});
});
});
This is simple and avoids extra dependencies, but it can become repetitive. For larger apps, prefer reusable extension methods, custom requirements, or a security library.
Scope-Based Authorization with Microsoft.Identity.Web
In Microsoft Entra-based APIs, Microsoft.Identity.Web provides helpers such as required-scope checks.
Example concept:
[Authorize]
[RequiredScope("orders.read")]
[HttpGet("orders")]
public IActionResult GetOrders()
{
return Ok();
}
This style is convenient when the project uses Microsoft.Identity.Web and Microsoft Entra ID.
Even with helper attributes, the underlying idea is the same:
- Validate the access token.
- Read the scope claim.
- Verify the endpoint's required scope.
- Return
403if the token lacks the required permission.
Service-to-Service Authorization
Service-to-service calls often use application permissions rather than user-delegated scopes.
Example app-only token:
{
"aud": "api://orders-api",
"client_id": "background-worker",
"roles": [
"Orders.Import"
]
}
Policy:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Orders.Import", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("Orders.Import");
});
});
Endpoint:
[Authorize(Policy = "Orders.Import")]
[HttpPost("orders/import")]
public IActionResult ImportOrders()
{
return Accepted();
}
A strong interview answer should mention that service-to-service security is not the same as user-delegated security. A daemon app may not have a user, so scp may be absent and roles may carry app permissions.
Combining User Scopes and App Roles
Some APIs support both delegated user tokens and app-only tokens.
Example requirement:
- User token with
orders.readscope can read orders. - App-only token with
Orders.Read.Allapp role can read orders.
Policy:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Orders.Read.AnyCaller", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context =>
HasScope(context.User, "orders.read") ||
context.User.IsInRole("Orders.Read.All"));
});
});
Helper:
static bool HasScope(ClaimsPrincipal user, string requiredScope)
{
return user.FindAll("scp")
.Concat(user.FindAll("scope"))
.SelectMany(claim => claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Contains(requiredScope, StringComparer.Ordinal);
}
This makes the supported access model explicit.
Tenant and Audience Validation
For multi-tenant APIs, tenant validation is important.
A token may be valid from a trusted issuer but still not belong to an allowed tenant or customer.
Example check:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AllowedTenant", policy =>
{
policy.RequireClaim("tid", "tenant-001");
});
});
However, static tenant checks are not enough for many SaaS systems. Often, the API must check tenant access against the database or tenant configuration.
Audience validation is also critical. If your API accepts a token issued for another API, the token could be misused.
Good practice:
- Validate issuer.
- Validate audience.
- Validate tenant if relevant.
- Validate scopes or roles.
- Validate resource ownership.
API Gateway and Downstream APIs
In microservice systems, an API may receive a token and call another API.
Important patterns:
- Token validation still belongs at each trust boundary.
- Do not blindly forward tokens to unrelated services.
- Use delegated tokens when calling on behalf of a user.
- Use app-only tokens when a service acts as itself.
- Be clear about which service is the audience of each token.
- Avoid using one token for every service unless the architecture explicitly supports it.
A common mistake is accepting any token that looks valid without checking whether the token audience matches the API.
Common Mistakes
Common mistakes include:
- Treating JWT decoding as validation.
- Accepting unsigned or weakly validated tokens.
- Disabling issuer, audience, signature, or lifetime validation.
- Accepting ID tokens as API access tokens.
- Using
[Authorize]without checking required scopes or roles. - Assuming a valid token means access to all resources.
- Not checking tenant or resource ownership.
- Using
RequireClaim("scp", "orders.read")when the scope claim is space-separated. - Confusing scopes, claims, roles, and permissions.
- Putting too much authorization state inside tokens.
- Using long-lived access tokens without a revocation strategy.
- Trusting frontend authorization checks.
- Returning inconsistent
401and403responses. - Forgetting
UseAuthentication()beforeUseAuthorization(). - Not testing authorization failures.
Best Practices
Use a trusted identity provider to issue access tokens.
Validate signature, issuer, audience, and expiration.
Use access tokens for APIs, not ID tokens.
Use [Authorize] to require authentication, but use policies to express permissions.
Verify scopes for delegated user access.
Verify app roles for daemon or service-to-service access.
Use policy-based authorization for maintainable access rules.
Use custom requirements and handlers for complex logic.
Use resource-based authorization for ownership, tenant, and row-level rules.
Keep token contents minimal.
Use short-lived access tokens.
Use constants for policy, scope, role, and permission names.
Test security behavior with anonymous requests, invalid tokens, missing scopes, wrong roles, wrong tenants, and cross-resource access.