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.
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 refreshTokenStep 1: Add the Auth Domain
// 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
// 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);
});
}
}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.ApiStep 3: Token Service
// 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
// 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
// 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();// appsettings.Development.json
{
"Jwt": {
"Secret": "dev-only-secret-replace-in-prod-32chars!!",
"Issuer": "orderflow-api",
"Audience": "orderflow-clients"
}
}Step 6: Secure the Order Endpoints
// 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
# 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.