Our API can create and reserve products — for anyone on the network. This part adds authentication with JSON Web Tokens: a token endpoint that verifies credentials and signs a JWT, bearer validation on every request, and role-based authorization on the sensitive endpoints. Every response below was captured from the running .NET 10 solution.

How JWT auth works in one paragraph
The client POSTs credentials once; the server returns a signed token containing claims (who you are, what roles you hold, when it expires). The client sends that token in the Authorization: Bearer header on every request. The server validates the signature and claims without any session storage — the token carries its own proof. That statelessness is why JWT fits APIs and horizontal scaling so well, and also why revocation is its weak spot (more below).
Configuring validation
var jwtKey = builder.Configuration["Jwt:Key"] ?? "dev-only-key-change-me-32-characters!";
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "roadmap-api",
ValidAudience = "roadmap-clients",
IssuerSigningKey = signingKey,
ClockSkew = TimeSpan.FromSeconds(30),
});
builder.Services.AddAuthorization();
And the two lines whose order matters — authentication identifies, authorization judges:
app.UseAuthentication();
app.UseAuthorization();
Notes that save production pain:
- The key never lives in source. The fallback above is for
dotnet runon a laptop; in deployment the key arrives via environment/secret store (Jwt__Key). 32+ bytes for HMAC-SHA256. - Symmetric vs. asymmetric: one service validating its own tokens → symmetric HMAC is fine. Multiple services validating tokens issued elsewhere → RS256 with a public key, or better, a real identity provider (Entra ID, Auth0, Keycloak) and this same
AddJwtBearerpointed at its authority URL. The validation side of this part transfers unchanged. - Default
ClockSkewis 5 minutes — your "30-minute" token lives 35. Tighten it consciously.
Issuing tokens
app.MapPost("/auth/token", ([FromBody] TokenRequest request) =>
{
if (request is not { Username: "demo", Password: "demo123!" })
return Results.Unauthorized();
var token = new JwtSecurityToken(
issuer: "roadmap-api",
audience: "roadmap-clients",
claims: [new Claim(ClaimTypes.Name, request.Username), new Claim(ClaimTypes.Role, "manager")],
expires: DateTime.UtcNow.AddMinutes(30),
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256));
return Results.Ok(new { access_token = new JwtSecurityTokenHandler().WriteToken(token), expires_in = 1800 });
});
The credential check is deliberately a stub — swap in your user store with a proper password hash (ASP.NET Core Identity's PasswordHasher, or passwordless OTP like geeksarray.com uses). The token shape is the part that stays.
Protecting endpoints
[HttpPost]
[Authorize(Roles = "manager")]
public async Task<IActionResult> Create([FromBody] CreateProductRequest request, CancellationToken ct) { ... }
[HttpPost("{id:int}/reserve")]
[Authorize]
public async Task<IActionResult> Reserve(int id, [FromQuery] int quantity, CancellationToken ct) { ... }
The verified flow
POST /products (no token) → 401
POST /auth/token (wrong password) → 401
POST /auth/token demo / demo123! → 200
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ...","expires_in":1800}
POST /products Bearer <token> → 200
{"id":4,"name":"Webcam","price":59,"stock":9}
Decode the token on jwt.io (it's base64, not encrypted!) and you'll see the claims: unique_name: demo, role: manager, iss, aud, exp. That visibility is the reminder: never put secrets in claims — anyone holding the token can read them; the signature only prevents modification.
Refresh tokens: short access life without constant logins
Thirty-minute access tokens are safe but annoying without a second credential. The refresh-token flow fixes the trade-off:
- The token endpoint returns two tokens: the short-lived JWT and an opaque, random refresh token stored server-side (hashed, with expiry and the user id).
- When the JWT expires, the client posts the refresh token to
/auth/refresh, which validates it against the store, rotates it (issue a new one, invalidate the old), and returns a fresh JWT. - Logout and "sign out everywhere" become row deletes — the revocation story JWTs alone lack.
public sealed class RefreshToken
{
public long Id { get; set; }
public required long UserId { get; set; }
public required string TokenHash { get; set; } // SHA-256 of the random value
public DateTime ExpiresAt { get; set; } // days–weeks, not months
public DateTime? RevokedAt { get; set; }
public string? ReplacedByHash { get; set; } // rotation chain
}
Rotation's quiet superpower: if a used refresh token shows up again, someone replayed a stolen token — revoke the whole chain and force re-login. That detection is impossible with long-lived access tokens.
Claims-based and policy-based authorization
[Authorize(Roles = "manager")] is the entry point, but roles get clumsy as rules grow ("managers can create products, but only senior managers above ₹1 lakh"). Policies name the rule instead of the group:
builder.Services.AddAuthorizationBuilder()
.AddPolicy("CanCreateProducts", p => p.RequireRole("manager"))
.AddPolicy("CanApproveHighValue", p =>
p.RequireAssertion(ctx => ctx.User.HasClaim("approval_limit", "high")));
[Authorize(Policy = "CanCreateProducts")]
public async Task<IActionResult> Create(...)
Controllers now reference the policy name; what the policy means lives in one registration. When the rule changes ("also allow inventory leads"), you edit one lambda, not thirty attributes. For rules needing services or database checks, implement IAuthorizationHandler — the mechanism scales from "has a role" to "owns this resource" without changing a single controller.
The parts teams get wrong
- Long-lived access tokens. Keep them short (minutes, not days) and add a refresh-token flow for session longevity. A stolen 30-minute token is an incident; a stolen 30-day token is a breach.
- No revocation story. Stateless means a valid token stays valid until expiry. Short lifetimes bound the damage; for instant kill-switches, keep a small denylist or a per-user "security stamp" claim checked against the store on sensitive operations.
- Tokens in localStorage for browser apps. XSS reads localStorage. For SPAs prefer HTTP-only cookies (with CSRF protection) or the BFF pattern; raw bearer-in-JS is best suited to server-to-server and mobile clients.
- Validating only the signature. Issuer, audience, and lifetime validation are what stop a token minted for another service being replayed against yours — the
TokenValidationParametersabove enable all three.
Testing authenticated endpoints
Auth code that isn't tested rots into "nobody dares touch the token endpoint." Two layers of coverage keep it honest. For handlers and policies, unit-test against a constructed ClaimsPrincipal — no HTTP involved:
var user = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Role, "manager")], authenticationType: "test"));
Assert.True(user.IsInRole("manager"));
For the wiring itself — does [Authorize] actually guard the route, does an expired token really 401 — integration tests with WebApplicationFactory are the truth:
[Fact]
public async Task Create_requires_a_token()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/products", new { name = "X", price = 1, stock = 1 });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Create_succeeds_with_manager_token()
{
var client = factory.CreateClient();
var token = await GetTokenAsync(client, "demo", "demo123!");
client.DefaultRequestHeaders.Authorization = new("Bearer", token);
var response = await client.PostAsJsonAsync("/products", new { name = "X", price = 1, stock = 1 });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
Because the demo issues real signed tokens from a config key, the test factory just supplies a test key via configuration — no mocking of the authentication stack, which is precisely the part you want exercised. Add one test for an expired token (issue with expires: DateTime.UtcNow.AddMinutes(-5)) and one for a wrong-audience token; those two catch the misconfigurations that production discovers otherwise.
Next
Authenticated users can now hit the read endpoints as hard as they like — and every hit runs SQL. Part 5 adds caching: in-memory first, the GetOrCreateAsync pattern, expiration trade-offs, and where Redis enters when one server becomes several.
Comments (0)
No comments yet — be the first to share your thoughts.