ASP.NET Core Web API · Cross-Cutting

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.

Intermediate 7 min Lesson 23 of 37Examples target .NET 9.
0/37 in ASP.NET Core Web API
On this page · 7 min · Intermediate

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#

C# Illustrative
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#

C#Program.cs Illustrative
// 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, }); }
Expected output
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#

  1. AddJwtBearer validates issuer, audience, signature and lifetime for every request with a Bearer token.
  2. Without a token, [Authorize] returns 401 with WWW-Authenticate: Bearer.
  3. A successful login returns a signed, three-part JWT that expires in 15 minutes.
  4. With the token, User is populated from its claims and /api/me returns them.
  5. 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#

Short or committed signing keys

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.

Long-lived access tokens

A stolen 30-day token is valid for 30 days. Keep access tokens short and use refresh tokens.

Secrets in claims

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?
User.FindFirst("sub")?.Value — remember that the JWT handler may map sub to ClaimTypes.NameIdentifier.
Show solution
C# Illustrative
// 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#

JWT Authentication quizMultiple choice Question 1 of 3

Which header carries the JWT on each request?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.