.NET & C# Development · Lesson 190 of 229
OrderFlow Part 2: JWT Auth — Access Tokens & Rotating Refresh Tokens
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
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.ApiStep 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.