DEV_NET_CORE
GET_STARTED
.NETAuthentication, authorization, and web security

JWT Bearer Auth, Claims, Scopes, and Policy-Based Authorization

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:

Code
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:

Code
header.payload.signature
PartPurpose
HeaderDescribes token type and signing algorithm
PayloadContains claims
SignatureAllows the API to verify token integrity and trust

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:

Code
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:

Code
[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:

Code
[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.

Token TypeMain PurposeUsed By
Access tokenAuthorize access to an APIAPI
ID tokenProve user sign-in to a client appClient application
Refresh tokenObtain new tokensClient application

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:

ValidationWhy It Matters
SignatureConfirms the token was issued by a trusted authority and was not tampered with
IssuerConfirms the token came from the expected identity provider
AudienceConfirms the token was meant for this API
ExpirationRejects expired tokens
Token typeHelps avoid using the wrong token type
Required claimsEnsures the token contains the expected identity and permission data

Example:

Code
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:

Code
// 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.

Code
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:

Code
[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:

TypeMeaning
ClaimsPrincipalRepresents the current caller
ClaimsIdentityRepresents one identity inside the principal
ClaimA key-value statement about the caller
HttpContext.UserThe current request's ClaimsPrincipal

Example:

Code
[Authorize]
[HttpGet("claims")]
public IActionResult GetClaims()
{
    var claims = User.Claims.Select(claim => new
    {
        claim.Type,
        claim.Value
    });

    return Ok(claims);
}

Common JWT claims include:

ClaimMeaning
issIssuer
audAudience
expExpiration time
iatIssued-at time
subSubject identifier
client_id or azpClient application
scp or scopeDelegated scopes
rolesRoles or app permissions
tid or tenant_idTenant identifier
jtiToken identifier

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:

Code
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:

Code
permission: orders.read
permission: orders.write

A scope-like claim might look like this:

Code
scp: orders.read orders.write

A role-like claim might look like this:

Code
"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:

Code
orders.read

A token may contain scopes like this:

Code
{
  "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:

Code
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:

Code
{
  "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:

Code
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:

Code
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Orders.Read", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireScope("orders.read");
    });
});

Usage:

Code
[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:

Code
{
  "roles": [
    "Admin",
    "Orders.Manager"
  ]
}

ASP.NET Core can use roles with [Authorize(Roles = "...")].

Example:

Code
[Authorize(Roles = "Admin")]
[HttpDelete("orders/{id:int}")]
public IActionResult DeleteOrder(int id)
{
    return NoContent();
}

Multiple roles can be allowed:

Code
[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.

ConceptUsually RepresentsCommon Scenario
ScopeDelegated permission granted to a client acting for a userSPA calls API as signed-in user
RoleUser role or application permissionAdmin users or daemon apps
PermissionFine-grained application actionorders.approve, users.manage
ClaimStatement about user/clienttenant_id, department, email

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:

Code
User signs in -> SPA gets access token -> SPA calls API -> API sees user and scopes

The token might contain:

Code
{
  "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:

Code
Background service -> gets app-only token -> calls API

The token might contain:

Code
{
  "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.read scope.
  • A background import endpoint may require Orders.Import app 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:

Code
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:

Code
[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:

Code
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:

Code
using Microsoft.AspNetCore.Authorization;

public sealed class ScopeRequirement : IAuthorizationRequirement
{
    public ScopeRequirement(string scope)
    {
        Scope = scope;
    }

    public string Scope { get; }
}

Example handler:

Code
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:

Code
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:

Code
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 Manager role.
  • 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:

Code
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:

Code
[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:

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

Status CodeMeaning
401 UnauthorizedNo valid authentication was provided
403 ForbiddenAuthentication succeeded, but authorization failed

Examples:

ScenarioResult
Missing token401
Expired token401
Invalid signature401
Valid token but missing scope403
Valid token but wrong role403
Valid token but wrong tenant/resource403 or sometimes 404 depending on security design

Challenge() usually maps to authentication failure.

Code
return Challenge();

Forbid() usually maps to authorization failure.

Code
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:

Code
{
  "roles": ["Admin"]
}

Others may emit:

Code
{
  "role": "Admin"
}

ASP.NET Core role authorization depends on the configured role claim type.

Example:

Code
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:

Code
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, and orders:read without a clear convention.

Example constants:

Code
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:

Code
[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:

Code
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:

Code
[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 403 if 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:

Code
{
  "aud": "api://orders-api",
  "client_id": "background-worker",
  "roles": [
    "Orders.Import"
  ]
}

Policy:

Code
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Orders.Import", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireRole("Orders.Import");
    });
});

Endpoint:

Code
[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.read scope can read orders.
  • App-only token with Orders.Read.All app role can read orders.

Policy:

Code
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:

Code
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:

Code
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 401 and 403 responses.
  • Forgetting UseAuthentication() before UseAuthorization().
  • 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.

Interview Practice

PreviousCORS, secure headers, secret handling, and least privilegeNext UpConventions, Fluent API, Owned/Complex Data, and Relationship Mapping