JWT Authentication
The ShopSmart mobile app cannot use cookies the way a browser does, so the API issues JSON Web Tokens: after login, the app sends Authorization: Bearer <token> with every request, and the API validates the token’s signature, issuer, audience and expiry.
On this page · 7 min · Intermediate
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Intermediate
- Updated
- Oct 29, 2026
Related tutorials
In simple words#
A JWT is a signed wristband at an event: show it at every gate and staff trust it because the signature can’t be faked — no need to check the guest list again.
- JWT
- Signed token with user claims.
- Bearer token
- Sent in the Authorization header.
- Expiry
- Short lifetimes limit damage if stolen.
Learning objectives#
- Configure JWT bearer authentication with token validation parameters
- Issue signed tokens with claims at login
- Protect endpoints with [Authorize] and read claims
- Understand 401 responses and WWW-Authenticate
Explanation#
Add the Microsoft.AspNetCore.Authentication.JwtBearer package and call AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(…) with TokenValidationParameters: ValidIssuer, ValidAudience, IssuerSigningKey, and lifetime validation.
At login, verify the password (Identity or your own hashed store), then create a token with claims (sub, name, role) and a short expiry using JsonWebTokenHandler and a SigningCredentials key.
Clients send Authorization: Bearer <token>. UseAuthentication validates it and fills HttpContext.User; [Authorize] rejects missing or invalid tokens with 401 and a WWW-Authenticate: Bearer header that explains why (e.g. error="invalid_token", "The token expired").
Keep the signing key secret and long (at least 256 bits for HS256), store it in configuration secrets or Key Vault, or use asymmetric keys from an identity provider (Microsoft Entra ID, Auth0, Duende IdentityServer).
Access tokens should be short-lived (minutes). Use refresh tokens or the identity provider’s flows for long sessions, and never put sensitive data in token claims — anyone can read them.
Syntax#
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://shopsmart.example", ValidAudience = "shopsmart-api",
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
});
Code example: Login issues a token; [Authorize] checks it#
// Program.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders(); // keep the demo output clean
builder.Services.AddControllers();
var jwt = new JwtSettings(
Issuer: "https://shopsmart.example",
Audience: "shopsmart-api",
Key: "demo-only-signing-key-change-me-at-least-32-bytes!"); // real apps: from secrets / Key Vault
builder.Services.AddSingleton(jwt);
builder.Services.AddSingleton<TokenService>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = jwt.Issuer,
ValidAudience = jwt.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key)),
ClockSkew = TimeSpan.Zero, // no grace period, for the demo
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Urls.Add("http://127.0.0.1:5223");
await app.StartAsync();
using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5223") };
var anonymous = await http.GetAsync("/api/me");
Console.WriteLine(quot;GET /api/me without token: {(int)anonymous.StatusCode} WWW-Authenticate: {anonymous.Headers.WwwAuthenticate}");
var badLogin = await http.PostAsJsonAsync("/api/auth/login", new { email = "asha@example.com", password = "guess" });
Console.WriteLine(quot;Login with wrong password: {(int)badLogin.StatusCode}");
var login = await http.PostAsJsonAsync("/api/auth/login", new { email = "asha@example.com", password = "Shop$mart2026" });
var token = (await login.Content.ReadFromJsonAsync<LoginResponse>())!;
Console.WriteLine(quot;Login: {(int)login.StatusCode}, token has {token.AccessToken.Split('.').Length} parts, expires in {token.ExpiresIn} s");
var request = new HttpRequestMessage(HttpMethod.Get, "/api/me");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
var me = await http.SendAsync(request);
Console.WriteLine(quot;GET /api/me with token: {(int)me.StatusCode} {await me.Content.ReadAsStringAsync()}");
var expired = app.Services.GetRequiredService<TokenService>().CreateToken("asha", "Asha", "Customer", TimeSpan.FromSeconds(-30));
var old = new HttpRequestMessage(HttpMethod.Get, "/api/me");
old.Headers.Authorization = new AuthenticationHeaderValue("Bearer", expired);
var rejected = await http.SendAsync(old);
Console.WriteLine(quot;GET /api/me with expired token: {(int)rejected.StatusCode} WWW-Authenticate: {rejected.Headers.WwwAuthenticate}");
await app.StopAsync();
// Auth/TokenService.cs
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
public record JwtSettings(string Issuer, string Audience, string Key);
public class TokenService(JwtSettings settings)
{
public string CreateToken(string userId, string name, string role, TimeSpan lifetime) =>
new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = settings.Issuer,
Audience = settings.Audience,
Subject = new ClaimsIdentity([new Claim("sub", userId), new Claim("name", name), new Claim("role", role)]),
NotBefore = DateTime.UtcNow.AddMinutes(-1), // small backdate for clock differences
Expires = DateTime.UtcNow.Add(lifetime),
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.Key)), SecurityAlgorithms.HmacSha256),
});
}
// Controllers/AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
public record LoginRequest(string Email, string Password);
public record LoginResponse(string AccessToken, int ExpiresIn);
[ApiController]
[Route("api")]
public class AuthController(TokenService tokens) : ControllerBase
{
[HttpPost("auth/login")]
public ActionResult<LoginResponse> Login(LoginRequest request)
{
// Demo user. Real apps verify a password hash with ASP.NET Core Identity.
if (request.Email != "asha@example.com" || request.Password != "Shop$mart2026") return Unauthorized();
var lifetime = TimeSpan.FromMinutes(15);
return new LoginResponse(tokens.CreateToken("asha", "Asha", "Customer", lifetime), (int)lifetime.TotalSeconds);
}
[Authorize]
[HttpGet("me")]
public IActionResult Me() => Ok(new
{
id = User.FindFirst("sub")?.Value ?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value,
name = User.FindFirst("name")?.Value,
});
}
GET /api/me without token: 401 WWW-Authenticate: Bearer
Login with wrong password: 401
Login: 200, token has 3 parts, expires in 900 s
GET /api/me with token: 200 {"id":"asha","name":"Asha"}
GET /api/me with expired token: 401 WWW-Authenticate: Bearer error="invalid_token", error_description="The token expired at '10/28/2026 22:06:32'"
(the expiry time varies per run)This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- AddJwtBearer validates issuer, audience, signature and lifetime for every request with a Bearer token.
- Without a token, [Authorize] returns 401 with WWW-Authenticate: Bearer.
- A successful login returns a signed, three-part JWT that expires in 15 minutes.
- With the token, User is populated from its claims and /api/me returns them.
- An expired token is rejected with 401, and the WWW-Authenticate header says why.
Real-world use case#
ShopSmart’s Android and iOS apps store the access token in secure storage and refresh it every 15 minutes. In production the tokens are issued by Microsoft Entra External ID, and the API only validates them.
Common mistakes#
A guessable or leaked HMAC key lets anyone mint admin tokens. Use long random keys from a secret store, or asymmetric keys from an identity provider.
A stolen 30-day token is valid for 30 days. Keep access tokens short and use refresh tokens.
JWT payloads are only Base64-encoded. Never put passwords, card data or internal secrets in them.
Best practices#
- Validate issuer, audience, signature and lifetime
- Short-lived access tokens
- Keys from secret storage or an identity provider
- HTTPS only — tokens are bearer credentials
Remember this#
Authorization: Bearer <token> → AddJwtBearer validates it.
No/invalid token → 401 + WWW-Authenticate.
Signed ≠ encrypted: anyone can read claims.
Practice exercise#
Your task: Add a [Authorize] GET /api/orders/mine endpoint that returns the caller’s user id from the sub claim, and returns 401 for anonymous callers.
Need a hint?
Show solution
// Controllers/MyOrdersController.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/orders")]
public class MyOrdersController : ControllerBase
{
[Authorize]
[HttpGet("mine")]
public IActionResult Mine()
{
var userId = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Ok(new { userId, orders = Array.Empty<int>() });
}
}
Mini quiz#
Which header carries the JWT on each request?
Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions
My notes#
Saved in this browser.