All articles

Modern .NET Backend Roadmap — Part 4: JWT Authentication

Issue and validate JSON Web Tokens in ASP.NET Core: token endpoint, bearer validation, role-based authorization, and the mistakes that turn tokens into breaches — with verified 401/403/200 flows.

0 · log in to like, save & follow Share on LinkedIn Share on X

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.

Modern .NET Backend Roadmap — Part 4: JWT Authentication

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 run on 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 AddJwtBearer pointed at its authority URL. The validation side of this part transfers unchanged.
  • Default ClockSkew is 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:

  1. 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).
  2. 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.
  3. 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 TokenValidationParameters above 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)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.