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?
Real-world analogy
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
- Authentication versus authorization — Authentication answers who you are. Authorization answers what you may do. A JWT mostly proves the first, and claims inside it help with the second.
- Three parts of a JWT — A JWT is header, payload and signature, each Base64Url-encoded and joined by dots. The header names the algorithm. The payload holds claims such as user id and expiry.
- Readable, not secret — Anyone can decode the payload with no key. Signing proves nobody changed it, but it does not hide it. Never put passwords or secrets in claims.
- Verified, not looked up — The API checks the signature with its key: the shared secret, or the issuer's public key. No token lookup in a database is needed, so any server can check it.
- What validation must check — Check the signature, the issuer, the audience and the lifetime. Skipping any one of these lets tokens from the wrong source or the wrong time through.
- Short access, long refresh — Access tokens live minutes, so a stolen one stops working soon. A refresh token, stored and revocable on the server, gets a new access token without a new login.
- The revocation trade-off — A valid JWT works until it expires, even if you ban the user. Short lifetimes and refresh-token checks are how you limit that window.
- Where the client stores it — An httpOnly cookie cannot be read by scripts, but it needs SameSite and CSRF protection. A token kept in memory is lost on page reload. Avoid localStorage, because any injected script can read it.
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. 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. AddJwtBearer sets the rules. Issuer, audience, lifetime and signing key are all validated.
- 3. UseAuthentication reads the Bearer token and builds the user. UseAuthorization then enforces access rules.
- 4. /login checks the credentials. A wrong password returns 401 and no token is created.
- 5. The claims array holds the user id. It holds no password and no secret, because the payload is readable.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Overview of ASP.NET Core authentication (Microsoft Learn)
- Configure JWT bearer authentication in ASP.NET Core (Microsoft Learn)
Lock it in