.NET & C# Development · Lesson 33 of 92
Build JWT Auth from Zero — No Libraries, Just Code
What We're Building
OrderFlow needs real authentication. By the end of this lesson:
- Users can register and log in
- Every protected endpoint requires a valid JWT
- Tokens expire after 60 minutes (short-lived is safer)
- Refresh tokens let users stay logged in for 30 days without re-entering credentials
- Admins and regular users have different permissions
Setup: ASP.NET Core Identity
Bash
dotnet add OrderFlow.Infrastructure package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add OrderFlow.Api package Microsoft.AspNetCore.Authentication.JwtBearerC#
// OrderFlow.Infrastructure/Identity/AppUser.cs
public class AppUser : IdentityUser<Guid>
{
public string FullName { get; set; } = string.Empty;
public Guid? CustomerId { get; set; } // links to Customer entity
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
// Refresh token storage
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiresAt { get; set; }
}C#
// Update AppDbContext to use Identity
public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<AppUser, IdentityRole<Guid>, Guid>(options), IUnitOfWork
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Product> Products => Set<Product>();
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}Register Identity + JWT in DI
C#
// OrderFlow.Infrastructure/DependencyInjection.cs
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// ... DbContext setup ...
services
.AddIdentity<AppUser, IdentityRole<Guid>>(opt =>
{
opt.Password.RequiredLength = 8;
opt.Password.RequireUppercase = true;
opt.Password.RequireDigit = true;
opt.Password.RequireNonAlphanumeric = false;
opt.User.RequireUniqueEmail = true;
opt.Lockout.MaxFailedAccessAttempts = 5;
opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
// Register our token service
services.AddScoped<IJwtTokenService, JwtTokenService>();
return services;
}C#
// OrderFlow.Api/Extensions/ServiceCollectionExtensions.cs
public static IServiceCollection AddJwtAuthentication(
this IServiceCollection services,
IConfiguration configuration)
{
var jwtSettings = configuration.GetSection("Jwt").Get<JwtSettings>()!;
services.Configure<JwtSettings>(configuration.GetSection("Jwt"));
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtSettings.SecretKey)),
ValidateIssuer = true,
ValidIssuer = jwtSettings.Issuer,
ValidateAudience = true,
ValidAudience = jwtSettings.Audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero, // ← important: no 5-min grace period
};
});
services.AddAuthorization(opt =>
{
opt.AddPolicy("Admin", p => p.RequireRole("Admin"));
opt.AddPolicy("Customer", p => p.RequireRole("Customer", "Admin"));
});
return services;
}JWT Token Service
C#
// OrderFlow.Infrastructure/Identity/JwtTokenService.cs
public class JwtTokenService(
IOptions<JwtSettings> settings,
UserManager<AppUser> userManager) : IJwtTokenService
{
private readonly JwtSettings _settings = settings.Value;
public async Task<TokenResponse> GenerateTokensAsync(AppUser user)
{
var claims = await BuildClaimsAsync(user);
var accessToken = CreateAccessToken(claims);
var refreshToken = GenerateRefreshToken();
// Persist refresh token (hashed)
user.RefreshToken = HashToken(refreshToken);
user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(_settings.RefreshTokenExpiryDays);
await userManager.UpdateAsync(user);
return new TokenResponse(
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: DateTime.UtcNow.AddMinutes(_settings.AccessTokenExpiryMinutes));
}
private async Task<List<Claim>> BuildClaimsAsync(AppUser user)
{
var roles = await userManager.GetRolesAsync(user);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email!),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("fullName", user.FullName),
};
// Add all roles as claims
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
if (user.CustomerId.HasValue)
claims.Add(new("customerId", user.CustomerId.Value.ToString()));
return claims;
}
private string CreateAccessToken(IEnumerable<Claim> claims)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.SecretKey));
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(_settings.AccessTokenExpiryMinutes),
Issuer = _settings.Issuer,
Audience = _settings.Audience,
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
};
var handler = new JwtSecurityTokenHandler();
return handler.WriteToken(handler.CreateToken(descriptor));
}
private static string GenerateRefreshToken()
{
var randomBytes = new byte[64];
RandomNumberGenerator.Fill(randomBytes);
return Convert.ToBase64String(randomBytes);
}
private static string HashToken(string token)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return Convert.ToBase64String(bytes);
}
}
public record TokenResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);Auth Endpoints
C#
// OrderFlow.Api/Endpoints/AuthEndpoints.cs
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/auth").WithTags("Auth");
group.MapPost("/register", Register);
group.MapPost("/login", Login);
group.MapPost("/refresh", Refresh);
group.MapPost("/logout", Logout).RequireAuthorization();
return app;
}
// POST /api/v1/auth/register
private static async Task<IResult> Register(
RegisterRequest request,
UserManager<AppUser> userManager,
IJwtTokenService tokenSvc,
CancellationToken ct)
{
var existing = await userManager.FindByEmailAsync(request.Email);
if (existing is not null)
return Results.Conflict(new ProblemDetails
{
Title = "Email already registered",
Status = 409,
});
var user = new AppUser
{
UserName = request.Email,
Email = request.Email,
FullName = request.FullName,
};
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
return Results.ValidationProblem(
result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }));
await userManager.AddToRoleAsync(user, "Customer"); // default role
var tokens = await tokenSvc.GenerateTokensAsync(user);
return Results.Created("/api/v1/auth/me", tokens);
}
// POST /api/v1/auth/login
private static async Task<IResult> Login(
LoginRequest request,
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
IJwtTokenService tokenSvc)
{
var user = await userManager.FindByEmailAsync(request.Email);
if (user is null)
return Results.Unauthorized(); // don't leak "user not found"
// CheckPasswordSignInAsync respects lockout
var signIn = await signInManager.CheckPasswordSignInAsync(
user, request.Password, lockoutOnFailure: true);
if (signIn.IsLockedOut)
return Results.Problem("Account locked. Try again in 15 minutes.", statusCode: 423);
if (!signIn.Succeeded)
return Results.Unauthorized();
user.LastLoginAt = DateTime.UtcNow;
await userManager.UpdateAsync(user);
var tokens = await tokenSvc.GenerateTokensAsync(user);
return Results.Ok(tokens);
}
// POST /api/v1/auth/refresh
private static async Task<IResult> Refresh(
RefreshRequest request,
UserManager<AppUser> userManager,
IJwtTokenService tokenSvc)
{
// Validate access token (without checking expiry) to extract userId
var principal = tokenSvc.GetPrincipalFromExpiredToken(request.AccessToken);
if (principal is null)
return Results.Unauthorized();
var userId = principal.FindFirstValue(JwtRegisteredClaimNames.Sub);
var user = await userManager.FindByIdAsync(userId!);
if (user is null
|| user.RefreshTokenExpiresAt < DateTime.UtcNow
|| user.RefreshToken != HashToken(request.RefreshToken))
{
return Results.Unauthorized();
}
var tokens = await tokenSvc.GenerateTokensAsync(user); // rotate both tokens
return Results.Ok(tokens);
}
// POST /api/v1/auth/logout
private static async Task<IResult> Logout(
ClaimsPrincipal currentUser,
UserManager<AppUser> userManager)
{
var userId = currentUser.FindFirstValue(JwtRegisteredClaimNames.Sub);
var user = await userManager.FindByIdAsync(userId!);
if (user is not null)
{
user.RefreshToken = null; // invalidate refresh token
user.RefreshTokenExpiresAt = null;
await userManager.UpdateAsync(user);
}
return Results.NoContent();
}Role Seeding
C#
// OrderFlow.Infrastructure/Persistence/DatabaseSeeder.cs
public static class DatabaseSeeder
{
public static async Task SeedRolesAsync(IServiceProvider services)
{
var roleManager = services.GetRequiredService<RoleManager<IdentityRole<Guid>>>();
string[] roles = ["Admin", "Customer", "Warehouse"];
foreach (var role in roles)
{
if (!await roleManager.RoleExistsAsync(role))
await roleManager.CreateAsync(new IdentityRole<Guid>(role));
}
}
public static async Task SeedAdminUserAsync(IServiceProvider services)
{
var userManager = services.GetRequiredService<UserManager<AppUser>>();
const string adminEmail = "admin@orderflow.dev";
if (await userManager.FindByEmailAsync(adminEmail) is not null) return;
var admin = new AppUser
{
UserName = adminEmail,
Email = adminEmail,
FullName = "OrderFlow Admin",
EmailConfirmed = true,
};
await userManager.CreateAsync(admin, "Admin@OrderFlow123!");
await userManager.AddToRoleAsync(admin, "Admin");
}
}
// In Program.cs (development only)
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
await DatabaseSeeder.SeedRolesAsync(scope.ServiceProvider);
await DatabaseSeeder.SeedAdminUserAsync(scope.ServiceProvider);
}Accessing the Current User in Handlers
C#
// OrderFlow.Application/Abstractions/ICurrentUserService.cs
public interface ICurrentUserService
{
Guid UserId { get; }
string Email { get; }
Guid? CustomerId { get; }
bool IsAdmin { get; }
}
// OrderFlow.Api/Services/CurrentUserService.cs
public class CurrentUserService(IHttpContextAccessor accessor) : ICurrentUserService
{
private ClaimsPrincipal User =>
accessor.HttpContext?.User
?? throw new InvalidOperationException("No HTTP context.");
public Guid UserId => Guid.Parse(User.FindFirstValue(JwtRegisteredClaimNames.Sub)!);
public string Email => User.FindFirstValue(JwtRegisteredClaimNames.Email)!;
public Guid? CustomerId => User.FindFirstValue("customerId") is string s ? Guid.Parse(s) : null;
public bool IsAdmin => User.IsInRole("Admin");
}
// Register
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();Now any handler can inject ICurrentUserService without touching HttpContext:
C#
public class GetMyOrdersQueryHandler(
IOrderRepository repo,
ICurrentUserService currentUser) : IRequestHandler<GetMyOrdersQuery, List<OrderDto>>
{
public async Task<List<OrderDto>> Handle(GetMyOrdersQuery q, CancellationToken ct)
{
// Customers only see their own orders; admins can see all
var customerId = currentUser.IsAdmin ? null : currentUser.CustomerId;
var orders = await repo.GetByCustomerAsync(customerId, ct);
return orders.Select(o => o.ToDto()).ToList();
}
}Protect Endpoints
C#
// Require any authenticated user
group.MapGet("/", GetOrders).RequireAuthorization();
// Require specific policy
group.MapDelete("/{id:guid}", DeleteOrder).RequireAuthorization("Admin");
// Allow anonymous (e.g., public product catalogue)
group.MapGet("/public", GetPublicProducts).AllowAnonymous();Security Checklist
✅ Passwords hashed by Identity (BCrypt internally)
✅ Short-lived access tokens (60 min)
✅ Refresh tokens are hashed before storage — raw token never saved
✅ Refresh token rotation — new refresh token issued on every refresh
✅ Logout invalidates the refresh token immediately
✅ Account lockout after 5 failed attempts
✅ ClockSkew = Zero — no 5-minute grace window for expired tokens
✅ Role-based authorization on sensitive endpoints
✅ Generic error messages — never reveal "user not found" vs "wrong password"Key Takeaways
- ASP.NET Core Identity handles password hashing, lockout, and user storage — don't implement these yourself
- Short access tokens + long refresh tokens is the industry standard — 60 min access, 30 day refresh
- Hash refresh tokens before storing — if your DB is breached, raw tokens cannot be replayed
- Rotate refresh tokens — issue a new one on every refresh; old one becomes invalid
ICurrentUserServicekeeps HTTP concerns out of your Application layer handlersClockSkew = TimeSpan.Zero— the default 5-minute grace period is a security hole on public APIs- Always return 401 Unauthorized for both "user not found" and "wrong password" — never confirm an email exists
Lesson Checkpoint
Quick CheckQuestion 1 of 4
Why should refresh tokens be hashed before storing in the database?