Module 3: Identity, Tokens, and Authorization

1:00 – 2:15 PM · Concepts 25 min · Lab 45 min

Present this module

Learning objectives

By the end of this module you can add ASP.NET Core Identity with token endpoints, protect endpoints with JWT bearer authentication, express permissions as policies rather than role checks scattered through code, and harden the API against the most common attacks.

Authentication and authorization are different questions

Authentication answers "who is this?" and produces a ClaimsPrincipal. Authorization answers "may they do this?" and produces allow or deny. They are separate middleware, and the order is not negotiable:

app.UseAuthentication();   // populates HttpContext.User
app.UseAuthorization();    // evaluates policies against it
app.MapProductEndpoints(); // endpoints come after both
flowchart LR
    Req[Request + Bearer token] --> A[UseAuthentication\nvalidate JWT → ClaimsPrincipal]
    A --> Z[UseAuthorization\nevaluate policy]
    Z -->|allowed| H[Handler]
    Z -->|no principal| U[401 Unauthorized]
    Z -->|principal, policy fails| F[403 Forbidden]

Two ways to get a token

MapIdentityApi<TUser>() gives you register, login, refresh, confirm-email, and password-reset endpoints for free. It is a superb starting point, and its /login returns a bearer token by default.

Issuing your own JWT gives you control over the claims. Roastery needs a customer_id claim and a role claim so that authorization can work without a database round-trip on every request.

We use both: Identity's endpoints for account management, and a small POST /api/auth/token that validates credentials with SignInManager and issues a JWT:

static async Task<Results<Ok<TokenResponse>, UnauthorizedHttpResult>> IssueToken(
    TokenRequest request, UserManager<RoasteryUser> users, IOptions<JwtOptions> jwt)
{
    var user = await users.FindByEmailAsync(request.Email);
    if (user is null || !await users.CheckPasswordAsync(user, request.Password))
        return TypedResults.Unauthorized();

    var roles = await users.GetRolesAsync(user);
    var claims = new List<Claim>
    {
        new(JwtRegisteredClaimNames.Sub, user.Id),
        new(JwtRegisteredClaimNames.Email, user.Email!),
        new("customer_id", user.CustomerId.ToString()),
    };
    claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));

    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Value.SigningKey));
    var token = new JwtSecurityToken(
        issuer: jwt.Value.Issuer, audience: jwt.Value.Audience, claims: claims,
        expires: DateTime.UtcNow.AddHours(1),
        signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

    return TypedResults.Ok(new TokenResponse(new JwtSecurityTokenHandler().WriteToken(token)));
}

Validating the token

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        var jwt = builder.Configuration.GetSection("Jwt").Get<JwtOptions>()!;
        options.TokenValidationParameters = new()
        {
            ValidIssuer = jwt.Issuer,
            ValidAudience = jwt.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)),
            ClockSkew = TimeSpan.FromSeconds(30),
        };
    });

The signing key never lives in appsettings.json. In development, use user secrets:

dotnet user-secrets set "Jwt:SigningKey" "a-development-key-that-is-at-least-32-bytes-long!"

In production it comes from an environment variable or a key vault, which is exactly how Module 5 passes it into the container.

Authorization as policy

Scattering if (user.IsInRole("Staff")) through handlers is how permission bugs are born. Declare the rules once as named policies and apply them to groups:

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("Staff", p => p.RequireRole("Staff"))
    .AddPolicy("OwnsOrder", p => p.AddRequirements(new OwnsOrderRequirement()));
var staff = app.MapGroup("/api/products").RequireAuthorization("Staff");
staff.MapPost("/", CreateProduct);
staff.MapPut("/{id:int}", UpdateProduct);
staff.MapDelete("/{id:int}", DeleteProduct);

Applying the policy to the group means a new endpoint added tomorrow is protected by default. Applying it endpoint by endpoint means a new endpoint is open by default. Choose the default that fails safe.

Resource-based authorization

"A customer may see only their own orders" is not a role check; it depends on the order. That is an IAuthorizationHandler that receives the resource:

public class OwnsOrderHandler : AuthorizationHandler<OwnsOrderRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, OwnsOrderRequirement requirement, Order order)
    {
        if (context.User.IsInRole("Staff") ||
            context.User.FindFirstValue("customer_id") == order.CustomerId.ToString())
            context.Succeed(requirement);
        return Task.CompletedTask;
    }
}
static async Task<Results<Ok<OrderDto>, NotFound, ForbidHttpResult>> GetOrder(
    int id, ClaimsPrincipal user, RoasteryDbContext db, IAuthorizationService auth, CancellationToken ct)
{
    var order = await db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct);
    if (order is null) return TypedResults.NotFound();

    var result = await auth.AuthorizeAsync(user, order, "OwnsOrder");
    return result.Succeeded ? TypedResults.Ok(order.ToDto()) : TypedResults.NotFound();
}

Notice the last line returns NotFound, not Forbid, when a customer asks for someone else's order. A 403 confirms the order exists; a 404 reveals nothing. The lab asks you to decide which is right for each endpoint.

Never trust the body for identity

In Lab 2, CreateOrder hard-coded CustomerId = 1. The fix is not to accept it from the request; it is to read it from the token:

var customerId = int.Parse(user.FindFirstValue("customer_id")!);

A request body that carries customerId is an invitation to place orders as someone else. This is OWASP API1, Broken Object Level Authorization, and it is the most common API vulnerability in the wild.

Hardening quick hits

Each of these is a few lines. The lab adds all of them.

Concern What you add Why
Brute force on login AddRateLimiter with a fixed window on /api/auth/* A handful of attempts a minute is plenty for a human and useless for a script
Abuse elsewhere A global sliding-window limiter partitioned by client IP or user Protects the database from any single caller
Cross-origin calls AddCors with an explicit origin list AllowAnyOrigin with credentials is a vulnerability, not a convenience
Transport UseHttpsRedirection and UseHsts outside development Tokens in transit are only as safe as the transport
Response headers A small middleware that sets X-Content-Type-Options, Referrer-Policy, and removes Server Cheap defense in depth
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddFixedWindowLimiter("auth", o =>
    {
        o.Window = TimeSpan.FromMinutes(1);
        o.PermitLimit = 5;
    });
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
        RateLimitPartition.GetSlidingWindowLimiter(
            ctx.User.Identity?.Name ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "anon",
            _ => new() { Window = TimeSpan.FromSeconds(10), PermitLimit = 100, SegmentsPerWindow = 5 }));
});
// …
app.UseRateLimiter();
app.MapGroup("/api/auth").RequireRateLimiting("auth");

The OWASP API Top 10 in five minutes

# Risk Where Roastery addresses it
API1 Broken object level authorization OwnsOrder handler; customer_id from the token
API2 Broken authentication Identity + JWT with expiry, issuer, and audience checks
API3 Broken object property level authorization DTOs; the client cannot set Total or Status
API4 Unrestricted resource consumption Rate limiting; clamped pageSize
API5 Broken function level authorization Staff policy on the group
API8 Security misconfiguration Explicit CORS, HSTS, secure headers, no Server header
API9 Improper inventory management OpenAPI document, versioning in Module 5

Head to the lab

Lab 3: Customers and Baristas