Backend Systemsbeginner
JWT Auth from Scratch in ASP.NET Core
Understand JWT authentication fundamentals: how tokens work, how to issue and validate them in ASP.NET Core, and what to watch out for in production.
Asma HafeezApril 17, 20264 min read
dotnetjwtauthenticationaspnet-coresecurity
JWT Auth from Scratch in ASP.NET Core
JWT (JSON Web Token) is the most common authentication mechanism for REST APIs. This guide explains how it works and how to implement it cleanly in ASP.NET Core.
How JWT Works
1. Client sends credentials: POST /auth/login { email, password }
2. Server validates, creates JWT, sends it back
3. Client stores token (memory or localStorage)
4. Client sends token in every request: Authorization: Bearer
5. Server validates token on each request ā no database lookup needed A JWT has three parts separated by dots:
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiIxMjMifQ . signature
Header (base64) Payload (base64) SignatureThe server signs the token with a secret key. Any modification invalidates the signature.
Install the Package
Bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearerConfigure in Program.cs
C#
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// JWT settings from appsettings.json
var jwtKey = builder.Configuration["Jwt:Key"]!;
var jwtIssuer = builder.Configuration["Jwt:Issuer"]!;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtIssuer,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ClockSkew = TimeSpan.Zero // no grace period ā token expires exactly at exp
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication(); // must come before UseAuthorization
app.UseAuthorization();appsettings.json
JSON
{
"Jwt": {
"Key": "your-secret-key-minimum-32-characters-long",
"Issuer": "https://yourapp.com"
}
}Token Service
C#
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using System.Text;
public class TokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config) => _config = config;
public string CreateToken(int userId, string email, string role)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.Email, email),
new Claim(ClaimTypes.Role, role),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Issuer"],
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}Auth Endpoints
C#
app.MapPost("/auth/login", async (LoginRequest req, TokenService tokens, IUserService users) =>
{
var user = await users.ValidateAsync(req.Email, req.Password);
if (user is null) return Results.Unauthorized();
var token = tokens.CreateToken(user.Id, user.Email, user.Role);
return Results.Ok(new { token, expiresIn = 3600 });
});
// Protected endpoint
app.MapGet("/me", (HttpContext ctx) =>
{
var userId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
var email = ctx.User.FindFirstValue(ClaimTypes.Email);
return Results.Ok(new { userId, email });
}).RequireAuthorization();
// Role-protected endpoint
app.MapDelete("/admin/users/{id}", (int id) => Results.Ok())
.RequireAuthorization(policy => policy.RequireRole("Admin"));
record LoginRequest(string Email, string Password);Reading Claims in Controllers
C#
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class ProfileController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var email = User.FindFirstValue(ClaimTypes.Email);
var role = User.FindFirstValue(ClaimTypes.Role);
return Ok(new { UserId = userId, Email = email, Role = role });
}
}Production Checklist
ā Secret key is at least 32 characters ā store in Azure Key Vault or env var
ā Set ClockSkew = TimeSpan.Zero ā predictable expiry
ā Use short-lived access tokens (15ā60 min) + refresh tokens
ā Include jti (JWT ID) claim for token revocation
ā Validate audience ā prevents token reuse across services
ā Use HTTPS only ā JWTs in plaintext are readable
ā Don't store sensitive data in the payload ā it's base64, not encryptedKey Takeaways
- JWT is stateless ā no database lookup per request (the signature proves authenticity)
- The payload is base64-encoded, not encrypted ā never put passwords or sensitive PII in claims
- Short expiry + refresh tokens is the production pattern ā not long-lived access tokens
UseAuthentication()must come beforeUseAuthorization()in the middleware pipeline- Store the secret key in environment variables or a secrets manager ā never in source code
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.