.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.JwtBearer
C#
// 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
  • ICurrentUserService keeps HTTP concerns out of your Application layer handlers
  • ClockSkew = 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?