Learnixo
Back to blog
Backend Systemsintermediate

OrderFlow: JWT Authentication & Refresh Tokens

Add JWT authentication with refresh tokens to the OrderFlow API: token generation, refresh rotation, role-based authorization, and securing every endpoint end-to-end.

Asma Hafeez KhanMay 25, 20266 min read
.NETC#JWTauthenticationASP.NET CoreOrderFlowsecurity
Share:š•

OrderFlow: JWT Authentication & Refresh Tokens

This is part 2 of the OrderFlow project series. We left off with a running API skeleton. Now we secure it: JWT access tokens, rotating refresh tokens, role-based authorization, and a locked-down endpoint structure.

Starting point: OrderFlow setup complete — DI, logging, health checks running.


What We're Adding

Before this lesson:
  GET /api/orders → 200 (anyone can call it — no auth)

After this lesson:
  GET  /api/orders     → 401 unless Bearer token present
  POST /api/orders     → 401 unless Customer or Admin role
  DELETE /api/orders   → 401 unless Admin role

  POST /api/auth/login   → returns accessToken + refreshToken
  POST /api/auth/refresh → exchanges refreshToken for new pair
  POST /api/auth/logout  → revokes refreshToken

Step 1: Add the Auth Domain

C#
// src/OrderFlow.Core/Entities/User.cs
public class User
{
    public int      Id           { get; set; }
    public string   Email        { get; set; } = "";
    public string   PasswordHash { get; set; } = "";
    public string   Role         { get; set; } = "Customer";   // Customer | Admin
    public DateTime CreatedAt    { get; set; }
    public bool     IsActive     { get; set; } = true;
}

// src/OrderFlow.Core/Entities/RefreshToken.cs
public class RefreshToken
{
    public int      Id        { get; set; }
    public int      UserId    { get; set; }
    public User     User      { get; set; } = null!;
    public string   Token     { get; set; } = "";
    public DateTime ExpiresAt { get; set; }
    public bool     IsRevoked { get; set; }
    public string?  ReplacedByToken { get; set; }   // rotation audit trail
    public DateTime CreatedAt { get; set; }

    public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
    public bool IsActive  => !IsRevoked && !IsExpired;
}

Step 2: EF Core — Add to DbContext

C#
// src/OrderFlow.Infrastructure/Data/OrderFlowDbContext.cs
public class OrderFlowDbContext(DbContextOptions<OrderFlowDbContext> opts) : DbContext(opts)
{
    public DbSet<Order>        Orders        => Set<Order>();
    public DbSet<User>         Users         => Set<User>();
    public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();

    protected override void OnModelCreating(ModelBuilder model)
    {
        model.Entity<User>(e =>
        {
            e.HasIndex(u => u.Email).IsUnique();
            e.Property(u => u.Role).HasMaxLength(32);
        });

        model.Entity<RefreshToken>(e =>
        {
            e.HasIndex(rt => rt.Token).IsUnique();
            e.HasOne(rt => rt.User)
             .WithMany()
             .HasForeignKey(rt => rt.UserId)
             .OnDelete(DeleteBehavior.Cascade);
        });
    }
}
Bash
dotnet ef migrations add AddAuth --project src/OrderFlow.Infrastructure --startup-project src/OrderFlow.Api
dotnet ef database update --project src/OrderFlow.Infrastructure --startup-project src/OrderFlow.Api

Step 3: Token Service

C#
// src/OrderFlow.Core/Services/ITokenService.cs
public interface ITokenService
{
    string          GenerateAccessToken(User user);
    RefreshToken    GenerateRefreshToken(int userId);
    ClaimsPrincipal ValidateAccessToken(string token);
}

// src/OrderFlow.Infrastructure/Auth/TokenService.cs
public class TokenService(IConfiguration config) : ITokenService
{
    public string GenerateAccessToken(User user)
    {
        var key   = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(config["Jwt:Secret"]!));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub,   user.Id.ToString()),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(ClaimTypes.Role,               user.Role),
            new Claim(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString()),
        };

        var token = new JwtSecurityToken(
            issuer:             config["Jwt:Issuer"],
            audience:           config["Jwt:Audience"],
            claims:             claims,
            expires:            DateTime.UtcNow.AddMinutes(15),   // short-lived
            signingCredentials: creds);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    public RefreshToken GenerateRefreshToken(int userId) => new()
    {
        UserId    = userId,
        Token     = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)),
        ExpiresAt = DateTime.UtcNow.AddDays(7),
        CreatedAt = DateTime.UtcNow,
    };

    public ClaimsPrincipal ValidateAccessToken(string token)
    {
        var handler    = new JwtSecurityTokenHandler();
        var parameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey         = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)),
            ValidateIssuer   = true,
            ValidIssuer      = config["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience    = config["Jwt:Audience"],
            ValidateLifetime = false,   // we only call this during refresh — token may be expired
        };

        return handler.ValidateToken(token, parameters, out _);
    }
}

Step 4: Auth Endpoints

C#
// src/OrderFlow.Api/Endpoints/AuthEndpoints.cs
public static class AuthEndpoints
{
    public static void MapAuthEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/api/auth").WithTags("Auth");

        group.MapPost("/register", Register);
        group.MapPost("/login",    Login);
        group.MapPost("/refresh",  Refresh);
        group.MapPost("/logout",   Logout).RequireAuthorization();
    }

    static async Task<IResult> Register(
        RegisterRequest req,
        OrderFlowDbContext db,
        CancellationToken ct)
    {
        if (await db.Users.AnyAsync(u => u.Email == req.Email, ct))
            return Results.Conflict("Email already registered.");

        var user = new User
        {
            Email        = req.Email.ToLowerInvariant(),
            PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password),
            Role         = "Customer",
            CreatedAt    = DateTime.UtcNow,
        };

        db.Users.Add(user);
        await db.SaveChangesAsync(ct);
        return Results.Created($"/api/users/{user.Id}", new { user.Id, user.Email, user.Role });
    }

    static async Task<IResult> Login(
        LoginRequest req,
        OrderFlowDbContext db,
        ITokenService tokens,
        CancellationToken ct)
    {
        var user = await db.Users
            .FirstOrDefaultAsync(u => u.Email == req.Email.ToLowerInvariant(), ct);

        if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
            return Results.Unauthorized();   // don't reveal which field was wrong

        if (!user.IsActive)
            return Results.Forbid();

        var accessToken  = tokens.GenerateAccessToken(user);
        var refreshToken = tokens.GenerateRefreshToken(user.Id);

        db.RefreshTokens.Add(refreshToken);
        await db.SaveChangesAsync(ct);

        return Results.Ok(new AuthResponse(accessToken, refreshToken.Token, user.Role));
    }

    static async Task<IResult> Refresh(
        RefreshRequest req,
        OrderFlowDbContext db,
        ITokenService tokens,
        CancellationToken ct)
    {
        var stored = await db.RefreshTokens
            .Include(rt => rt.User)
            .FirstOrDefaultAsync(rt => rt.Token == req.RefreshToken, ct);

        if (stored is null || !stored.IsActive)
            return Results.Unauthorized();

        // Rotate: revoke old token, issue new pair
        stored.IsRevoked = true;
        var newRefresh   = tokens.GenerateRefreshToken(stored.UserId);
        stored.ReplacedByToken = newRefresh.Token;

        db.RefreshTokens.Add(newRefresh);
        await db.SaveChangesAsync(ct);

        var newAccess = tokens.GenerateAccessToken(stored.User);
        return Results.Ok(new AuthResponse(newAccess, newRefresh.Token, stored.User.Role));
    }

    static async Task<IResult> Logout(
        LogoutRequest req,
        OrderFlowDbContext db,
        CancellationToken ct)
    {
        var token = await db.RefreshTokens
            .FirstOrDefaultAsync(rt => rt.Token == req.RefreshToken, ct);

        if (token is { IsActive: true })
        {
            token.IsRevoked = true;
            await db.SaveChangesAsync(ct);
        }

        return Results.NoContent();
    }
}

public record RegisterRequest(string Email, string Password);
public record LoginRequest(string Email, string Password);
public record RefreshRequest(string RefreshToken);
public record LogoutRequest(string RefreshToken);
public record AuthResponse(string AccessToken, string RefreshToken, string Role);

Step 5: Wire Up JWT Validation

C#
// src/OrderFlow.Api/Program.cs additions
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opts =>
    {
        opts.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)),
            ValidateIssuer   = true,
            ValidIssuer      = builder.Configuration["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience    = builder.Configuration["Jwt:Audience"],
            ValidateLifetime = true,
            ClockSkew        = TimeSpan.Zero,   // no tolerance — 15 min means 15 min
        };
    });

builder.Services.AddAuthorization(opts =>
{
    opts.AddPolicy("AdminOnly",    p => p.RequireRole("Admin"));
    opts.AddPolicy("CustomerOrAdmin", p => p.RequireRole("Customer", "Admin"));
});

builder.Services.AddScoped<ITokenService, TokenService>();

// After building the app:
app.UseAuthentication();   // must come before UseAuthorization
app.UseAuthorization();
JSON
// appsettings.Development.json
{
  "Jwt": {
    "Secret":   "dev-only-secret-replace-in-prod-32chars!!",
    "Issuer":   "orderflow-api",
    "Audience": "orderflow-clients"
  }
}

Step 6: Secure the Order Endpoints

C#
// Apply auth to the orders group
var orders = app.MapGroup("/api/orders")
    .WithTags("Orders")
    .RequireAuthorization();   // all routes need a valid token

orders.MapGet("/",        GetOrders);              // any authenticated user
orders.MapGet("/{id}",   GetOrder);               // any authenticated user
orders.MapPost("/",      CreateOrder);             // Customer or Admin
orders.MapPut("/{id}",   UpdateOrder)
    .RequireAuthorization("AdminOnly");            // Admin only
orders.MapDelete("/{id}", DeleteOrder)
    .RequireAuthorization("AdminOnly");            // Admin only

// Read the user ID from the JWT claim
static async Task<IResult> GetOrders(
    ClaimsPrincipal user,   // injected from the token
    IOrderRepository orders,
    CancellationToken ct)
{
    var userId = int.Parse(user.FindFirst(ClaimTypes.NameIdentifier)!.Value);
    var role   = user.FindFirst(ClaimTypes.Role)!.Value;

    // Admins see all orders; customers see only their own
    var result = role == "Admin"
        ? await orders.GetAllAsync(ct)
        : await orders.GetByCustomerAsync(userId, ct);

    return Results.Ok(result);
}

Step 7: Test the Auth Flow

Bash
# 1. Register
curl -X POST http://localhost:5000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"P@ssw0rd!"}'

# 2. Login → get tokens
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"P@ssw0rd!"}'
# → {"accessToken":"eyJ...","refreshToken":"abc123...","role":"Customer"}

# 3. Call a protected endpoint
curl http://localhost:5000/api/orders \
  -H "Authorization: Bearer eyJ..."

# 4. Refresh when access token expires
curl -X POST http://localhost:5000/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"abc123..."}'

# 5. Logout
curl -X POST http://localhost:5000/api/auth/logout \
  -H "Authorization: Bearer eyJ..." \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"abc123..."}'

What's Next

OrderFlow now has:

  • JWT access tokens (15-min lifetime)
  • Rotating refresh tokens (7-day lifetime, single-use)
  • Role-based authorization (Customer/Admin)
  • Revocation on logout

Next: OrderFlow CQRS — introduce MediatR commands and queries to replace the direct repository calls and prepare for domain events.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.