ASP.NET

Implementing JWT Authentication in ASP.NET Core

Adding JWT authentication to an ASP.NET Core API, from creating the project through to getting a token accepted by a protected endpoint.

ASP.NET JWT Authentication

You need the .NET 8 SDK or later, and a working familiarity with C# and REST APIs.

Create the project

dotnet new webapi -n JwtAuthDemo --use-controllers cd JwtAuthDemo dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer dotnet add package System.IdentityModel.Tokens.Jwt

Don't skip --use-controllers. As of .NET 8, dotnet new webapi scaffolds a Minimal API, so without that flag you get no Controllers folder and nothing below will line up.

Configuration

Add a JWT section to appsettings.json.

{ "Jwt": { "Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!", "Issuer": "https://yourdomain.com", "Audience": "https://yourdomain.com", "ExpiryMinutes": 60 }, "Logging": { "LogLevel": { "Default": "Information" } } }

Key signs the token. Because we're signing with HMAC-SHA256, the key has to be at least 256 bits — 32 bytes — or you get an exception at runtime. In ASCII, that's 32 characters and up. It also isn't a value that belongs in your repository; more on that at the end.

Issuer and Audience identify who minted the token and who it's for. Both are checked during validation, so the issuing side and the validating side have to agree.

Models

Two classes in a Models folder.

// LoginRequest.cs namespace JwtAuthDemo.Models; public class LoginRequest { public string Username { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; }
// LoginResponse.cs namespace JwtAuthDemo.Models; public class LoginResponse { public string Token { get; set; } = string.Empty; public DateTime Expiration { get; set; } }

The service that issues tokens

Services/JwtService.cs:

using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; namespace JwtAuthDemo.Services; public class JwtService { private readonly IConfiguration _configuration; public JwtService(IConfiguration configuration) { _configuration = configuration; } public string GenerateToken(string username, string role) { var claims = new[] { new Claim(ClaimTypes.Name, username), new Claim(ClaimTypes.Role, role), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; var key = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]!) ); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var expiry = DateTime.UtcNow.AddMinutes( Convert.ToDouble(_configuration["Jwt:ExpiryMinutes"]) ); var token = new JwtSecurityToken( issuer: _configuration["Jwt:Issuer"], audience: _configuration["Jwt:Audience"], claims: claims, expires: expiry, signingCredentials: credentials ); return new JwtSecurityTokenHandler().WriteToken(token); } }

All it does is assemble the claims — the user facts carried in the token — sign them with the secret, and serialise the result.

Jti is a unique ID per token. Nothing here uses it, but the day you need to revoke individual tokens you'll want it to have been there from the start.

One thing to be clear about: those values are Base64-encoded, not encrypted. Anyone holding the token can decode and read them. The signature proves the contents weren't altered; it doesn't hide them. Passwords and personal data don't go in claims.

Program.cs

using System.Text; using JwtAuthDemo.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSingleton<JwtService>(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!) ) }; }); builder.Services.AddAuthorization(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();

UseAuthentication() has to come before UseAuthorization(). Authentication establishes who the caller is; authorization reads that result and decides whether to let them through. Reversed, identity isn't resolved yet when the decision gets made and everything returns 401. It still compiles, which is what makes this one easy to miss.

Login and endpoints

// Controllers/AuthController.cs using JwtAuthDemo.Models; using JwtAuthDemo.Services; using Microsoft.AspNetCore.Mvc; namespace JwtAuthDemo.Controllers; [ApiController] [Route("api/[controller]")] public class AuthController : ControllerBase { private readonly JwtService _jwtService; public AuthController(JwtService jwtService) { _jwtService = jwtService; } [HttpPost("login")] public IActionResult Login([FromBody] LoginRequest request) { // For trying it out. Real code checks a hash in the database. if (request.Username == "admin" && request.Password == "password123") { var token = _jwtService.GenerateToken(request.Username, "Admin"); return Ok(new LoginResponse { Token = token, Expiration = DateTime.UtcNow.AddMinutes(60) }); } return Unauthorized(new { message = "Invalid credentials" }); } }

Hardcoded credentials are there so you can run this. In practice you check the submitted password against a stored hash, via ASP.NET Core Identity or your own user table.

The endpoints being protected:

// Controllers/WeatherController.cs using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace JwtAuthDemo.Controllers; [ApiController] [Route("api/[controller]")] public class WeatherController : ControllerBase { [HttpGet("public")] public IActionResult GetPublicData() { return Ok(new { message = "This is public data" }); } [Authorize] [HttpGet("protected")] public IActionResult GetProtectedData() { return Ok(new { message = "This is protected data", user = User.Identity?.Name }); } [Authorize(Roles = "Admin")] [HttpGet("admin")] public IActionResult GetAdminData() { return Ok(new { message = "Admin only" }); } }

Running it

dotnet run

The port comes from Properties/launchSettings.json. The examples below use 7000 — substitute whatever yours says.

Get a token:

curl -X POST https://localhost:7000/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"password123"}'
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expiration": "2026-01-23T11:00:00Z" }

Send it at the protected endpoint:

curl -X GET https://localhost:7000/api/weather/protected \ -H "Authorization: Bearer YOUR_TOKEN_HERE"
{ "message": "This is protected data", "user": "admin" }

Where this goes wrong

Everything returns 401. Check the shape of the Authorization header first: the Bearer prefix is required, with exactly one space after it. Often the header isn't being sent at all — curl -v settles that quickly.

Changing the key didn't take effect. Validation fails unless the signing key and the validating key match. Restart the app after editing appsettings.json.

An expired token still works. TokenValidationParameters.ClockSkew defaults to five minutes. It's deliberate — tolerance for clock drift between servers. Set ClockSkew = TimeSpan.Zero when you need expiry to be exact in tests.

User.Identity.Name is null. The standard JWT claim names (sub and friends) aren't the same as .NET's ClaimTypes, and JwtSecurityTokenHandler maps between them by default. Issue with ClaimTypes.Name as above and it resolves; issue with sub and expect User.Identity.Name to be populated and it won't line up. Everyone hits this once.

Before this goes to production

Get the signing key out of appsettings.json. That's the one that matters. Use environment variables, Azure Key Vault, or dotnet user-secrets while developing. A key that has been committed once should be treated as leaked, even after you rewrite history.

Beyond that: only ever carry tokens over HTTPS, keep expiry short (15–60 minutes), and add refresh tokens so that short expiry is livable. Those last two go together — shorten the lifetime without refresh tokens and you've just asked your users to log in every half hour.