Lesson 12 of 45 intermediate

Authentication with JWT

A signed ticket the server can check without looking anything up

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

A user pastes their JWT into a public decoder website. What can the site show?

Open interactive version (quiz + challenge)

Real-world analogy

Think of a concert wristband. At the door a guard checks your ID once and snaps a tamper-proof band on your wrist. Inside, staff do not call the box office. They only check that the band is genuine and has not expired. Here the band is the JWT and the guard is the login endpoint. The staff check is signature and expiry validation.

What is it?

A JSON Web Token is a compact string that carries claims about a user and a signature over them. After login the server creates one, signs it with a secret or private key, and returns it. The client sends it back in the Authorization: Bearer header. ASP.NET Core's JWT bearer middleware checks the signature and the rules you configure, then builds the user from the claims.

Real-world relevance

Most single-page apps with an API use this flow. The login endpoint checks the password hash, then issues a 15-minute access token and a refresh token. The React app attaches the access token to each call and quietly asks for a new one when it expires. The classic mistakes are tokens that never expire, a signing key committed to git, and validation switched off while debugging and never switched on again.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

How does the API check a JWT on each request?

Why use a short-lived access token with a refresh token?

Code example

var builder = WebApplication.CreateBuilder(args);
var key = new SymmetricSecurityKey(
    Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!));   // from secrets, not code

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidIssuer = "https://api.example.com",
        ValidateAudience = true,
        ValidAudience = "example-web",
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = key
    });
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/login", (LoginRequest req, IUserStore users) =>
{
    var user = users.Validate(req.Email, req.Password);   // compares password hashes
    if (user is null) return Results.Unauthorized();

    var claims = new[] { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()) };
    var token = new JwtSecurityToken("https://api.example.com", "example-web", claims,
        expires: DateTime.UtcNow.AddMinutes(15),
        signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
    return Results.Ok(new { accessToken = new JwtSecurityTokenHandler().WriteToken(token) });
});
app.Run();

Line-by-line walkthrough

  1. 1. The signing key is built from a string in configuration. It should come from user-secrets or the environment, never from source code. HmacSha256 needs a key of at least 32 bytes.
  2. 2. AddJwtBearer sets the rules. Issuer, audience, lifetime and signing key are all validated.
  3. 3. UseAuthentication reads the Bearer token and builds the user. UseAuthorization then enforces access rules.
  4. 4. /login checks the credentials. A wrong password returns 401 and no token is created.
  5. 5. The claims array holds the user id. It holds no password and no secret, because the payload is readable.
  6. 6. JwtSecurityToken sets issuer, audience, a 15-minute expiry and the signing credentials. WriteToken turns it into the final string.

Spot the bug

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidIssuer = "https://api.example.com",
        ValidateAudience = true,
        ValidAudience = "example-web",
        ValidateLifetime = false,
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = key
    });
Need a hint?
Which validation flag lets an old token keep working?
Show answer
ValidateLifetime = false means expired tokens are still accepted, so a stolen token works forever. Fix: set ValidateLifetime = true. Keep a short ClockSkew (the default is 5 minutes) and rely on refresh tokens for long sessions.

Explain like I'm 5

A guard checks your name once and gives you a stamped paper. After that, every door only checks that the stamp is real and not too old. Nobody has to call the guard again.

Fun fact

A normal signed JWT is only Base64Url-encoded, not encrypted. Paste one into a decoder and you can read its claims in seconds. That is why secrets do not belong in them.

Hands-on challenge

Say it out loud in 30 seconds: "How does JWT authentication work in ASP.NET Core and what are its risks?" Then check: did you mention the signature being verified without a lookup, the payload being readable, short expiry with refresh tokens, and where the client stores the token?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook