Module 3: Identity, Tokens, and Authorization
1:00 – 2:15 PM · Concepts 25 min · Lab 45 min
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:
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.
warning
HS256 requires a key of at least 256 bits (32 bytes). A shorter key throws at token creation with a message that is easy to misread as a configuration typo.
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:
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:
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.
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");