Secure, Extendable, and Frontend-Ready β€” the Architecture Your App Deserves

πŸ€” Why This Isn't Another Blog Post

Most JWT auth tutorials teach you just enough to get hacked.

They stop at generating access tokens. No refresh logic. No revocation strategy. No deployment safety. No frontend.

This one? You'll walk away with a blueprint.

A fully working JWT auth system using .NET 8 Minimal APIs, built with:

βœ… Secure login βœ… Access + refresh token generation βœ… Token blacklisting (so logout actually works) βœ… Role-based authorization βœ… Frontend-ready integration with React + Axios βœ… Dev/test/staging friendly setup with deployment notes βœ… GitHub Gist to clone, fork, or drop into your app

This isn't another "JWT 101" tutorial. This is the guide you'd ship to production β€” or send to your dev team on Slack.

🧰 What You'll Build β€” Feature Set

  • πŸ” POST /login with secure token issuance
  • πŸ” POST /refresh with rotating refresh tokens
  • ❌ Blacklisting revoked tokens (yes, logout works now)
  • πŸ›‘οΈ Role-based access with scoped policies
  • 🧱 Minimal API project, layered for real-world scaling
  • πŸ§ͺ Endpoint tests to catch regressions
  • βš›οΈ React + Axios integration for token handling
  • πŸš€ Deployment tips (Azure, secrets, Docker build)
None

πŸ” JWT + Refresh Token Logic

Secure Issuance, Rotation, and Real Expiry Management in .NET 8 Minimal APIs

In most tutorials, you get a JWT and move on. But in production, access tokens must expire quickly β€” and refresh tokens must be rotated, tracked, and revocable.

Let's build it right.

πŸ“¦ Project Setup

Packages you'll need:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.EntityFrameworkCore.InMemory

πŸ”§ In this walkthrough, we'll use EF Core In-Memory DB for the refresh token store β€” swappable later for PostgreSQL, Redis, or Cosmos DB.

πŸ”§ In this walkthrough, we'll use EF Core In-Memory DB for the refresh token store β€” swappable later for PostgreSQL, Redis, or Cosmos DB.

πŸ” Generate Access + Refresh Tokens

TokenService.cs β€” cleanly split responsibilities:

public class TokenService
{
    private readonly IConfiguration _config;
    private readonly IRefreshTokenStore _store;

    public TokenService(IConfiguration config, IRefreshTokenStore store)
    {
        _config = config;
        _store = store;
    }

    public async Task<AuthResult> GenerateTokensAsync(User user)
    {
        var accessToken = GenerateAccessToken(user);
        var refreshToken = GenerateSecureRefreshToken();

        await _store.SaveAsync(user.Id, refreshToken);

        return new AuthResult
        {
            AccessToken = accessToken,
            RefreshToken = refreshToken
        };
    }

    private string GenerateAccessToken(User user)
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.Name, user.Username),
            new Claim(ClaimTypes.Role, user.Role)
        };

        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

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

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

    private string GenerateSecureRefreshToken()
        => Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
}

πŸ” /login Endpoint

Minimal API Version (clean and injectable)

app.MapPost("/login", async (
    LoginRequest request,
    IUserService users,
    ITokenService tokens) =>
{
    var user = await users.ValidateAsync(request.Username, request.Password);

    if (user == null)
        return Results.Unauthorized();

    var result = await tokens.GenerateTokensAsync(user);

    return Results.Ok(result);
});

πŸ” /refresh Endpoint

Rotating Refresh Token on Every Use

app.MapPost("/refresh", async (
    RefreshRequest req,
    ITokenService tokenService) =>
{
    var result = await tokenService.RefreshAsync(req.RefreshToken);

    return result is null
        ? Results.Unauthorized()
        : Results.Ok(result);
});

πŸ” Refresh Token Blacklisting & Revocation Logic

Making Logout Real in .NET 8 β€” In-Memory Blacklist Pattern

If your refresh tokens can't be revoked, your logout endpoint is a lie.

🀯 The Problem Most Tutorials Ignore

In the majority of "secure" .NET samples, refresh tokens are:

  • Stateless ❌
  • Stored only on the client ❌
  • Never invalidated ❌

That means:

  • If someone steals a refresh token, it stays valid until expiry.
  • Logging out doesn't revoke anything.

You can't run a real app like that.

βœ… Blacklisting Done Right β€” With In-Memory Store

To support real revocation:

  1. Save refresh tokens server-side.
  2. Check token validity on every use.
  3. Invalidate old token after issuing a new one.

Let's build it.

πŸ’Ύ RefreshTokenStore.cs

A simple IRefreshTokenStore implementation:

public class RefreshTokenStore : IRefreshTokenStore
{
    private readonly Dictionary<string, string> _tokens = new();

    public Task SaveAsync(string userId, string refreshToken)
    {
        _tokens[userId] = refreshToken;
        return Task.CompletedTask;
    }

    public Task<string?> GetAsync(string userId)
    {
        _tokens.TryGetValue(userId, out var token);
        return Task.FromResult(token);
    }

    public Task InvalidateAsync(string userId)
    {
        _tokens.Remove(userId);
        return Task.CompletedTask;
    }
}

πŸ“Œ In production, replace with:

  • Redis (for multi-instance scaling)
  • EF Core with indexed token table
  • Cosmos DB for serverless models

πŸ” RefreshAsync() Logic

Update your token service:

public async Task<AuthResult?> RefreshAsync(string providedToken)
{
    var userId = GetUserIdFromToken(providedToken); // Optional: parse from payload

    var stored = await _store.GetAsync(userId);
    if (stored is null || stored != providedToken)
        return null;

    // Invalidate the old token
    await _store.InvalidateAsync(userId);

    var user = await _userService.GetByIdAsync(userId);
    if (user is null) return null;

    return await GenerateTokensAsync(user);
}

βœ… Now you:

  • Track refresh token per user
  • Invalidate on reuse
  • Protect against token theft
None

πŸ” Role-Based Access & Secure Middleware

πŸ”‘ Real RBAC in Minimal APIs Without Bloat

πŸ”’ Just slapping [Authorize] isn't enough β€” real-world apps need fine-grained control.

In this section, you'll learn to:

βœ… Protect endpoints based on user roles βœ… Configure JWT validation cleanly βœ… Avoid magic strings and scattered policies

πŸ› οΈ Configure JWT Authentication in Program.cs

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        var config = builder.Configuration;
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = config["Jwt:Issuer"],
            ValidAudience = config["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(config["Jwt:Key"]!)
            )
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
});

🧱 Securing Routes in Minimal API Style

Admin-only route:

app.MapGet("/admin/stats", () => "Secret stats")
   .RequireAuthorization("AdminOnly");

Authenticated users only:

app.MapGet("/profile", (ClaimsPrincipal user) =>
{
    var username = user.Identity?.Name;
    return $"Hello, {username}";
}).RequireAuthorization();

🎯 Tip: Avoid Role Scattering

Create a Roles.cs:

public static class Roles
{
    public const string Admin = "Admin";
    public const string User = "User";
}

Use it everywhere instead of "Admin" or "User" literals. This avoids bugs and makes refactoring safer.

None

βš›οΈ React + Axios Frontend Integration

πŸ§ͺ Syncing Auth State, Handling Token Expiry, and Refreshing Seamlessly

Most devs nail the backend β€” but the frontend still gets 401 Unauthorized after 15 minutes. Let's fix that with a real React setup.

πŸ”Œ Axios Interceptor for Auto-Refresh

We'll build:

  1. A secure login form
  2. A token-aware Axios client
  3. Automatic refresh when access token expires

🧾 Store Tokens in Memory or Secure Cookie

Avoid localStorage unless necessary. For simplicity here, we'll use a tokenService abstraction:

export const tokenService: {
  access: string;
  refresh: string;
  setTokens: (access: string, refresh: string) => void;
} = {
  access: '',
  refresh: '',
  setTokens: (access: string, refresh: string): void => {
    tokenService.access = access;
    tokenService.refresh = refresh;
  },
};

πŸ”„ Axios Setup with Auto Refresh

import axios, { AxiosRequestConfig, AxiosError } from 'axios';
import tokenService from './tokenService'; // Adjust path accordingly

interface RefreshResponse {
  accessToken: string;
  refreshToken: string;
}

const api = axios.create({
  baseURL: 'https://your-api.com',
  headers: { 'Content-Type': 'application/json' },
});

api.interceptors.request.use((config: AxiosRequestConfig) => {
  if (config.headers) {
    config.headers.Authorization = `Bearer ${tokenService.access}`;
  }
  return config;
});

api.interceptors.response.use(undefined, async (error: AxiosError) => {
  const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };

  if (
    error.response?.status === 401 &&
    !originalRequest._retry
  ) {
    originalRequest._retry = true;

    const res = await axios.post<RefreshResponse>('/refresh', {
      refreshToken: tokenService.refresh,
    });

    tokenService.setTokens(res.data.accessToken, res.data.refreshToken);
    if (originalRequest.headers) {
      originalRequest.headers.Authorization = `Bearer ${res.data.accessToken}`;
    }

    return api(originalRequest);
  }

  return Promise.reject(error);
});

export default api;

πŸ” Login Handler Example

import axios from 'axios';
import tokenService from './tokenService'; // Adjust path as needed

interface LoginResponse {
  accessToken: string;
  refreshToken: string;
}

const handleLogin = async (username: string, password: string): Promise<void> => {
  const res = await axios.post<LoginResponse>('/login', { username, password });

  tokenService.setTokens(res.data.accessToken, res.data.refreshToken);
};

Now your frontend will:

  • Automatically retry requests after token expiry
  • Stay authenticated until refresh token expires
  • Log out correctly when refresh token is revoked
None

πŸš€ Deployment Tips β€” Azure, Docker, Secrets, and Environment Safety

🧩 Making JWT Auth Production-Ready β€” Not Just Working on Localhost

You've got the backend working and the frontend synced. Now it's time to deploy securely β€” without leaking keys or hardcoding secrets.

🧰 Environment Secrets (Don't Hardcode Keys!)

πŸ”’ Move all secrets to appsettings.Production.json or use environment variables.

βœ… Sample appsettings.Production.json:

{
  "Jwt": {
    "Key": "Use_Env_Var_In_Production",
    "Issuer": "your-api.com",
    "Audience": "your-client-app.com"
  }
}

βœ… In Program.cs, override with env vars:

builder.Configuration
    .AddJsonFile("appsettings.Production.json", optional: true)
    .AddEnvironmentVariables();

🐳 Docker-Ready Setup

Minimal Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "JwtAuthApi.dll"]

πŸ” Use --env or .env for secrets when running:

docker run -e Jwt__Key=YOUR_SECRET -p 5000:80 your-image-name

☁️ Azure Deployment Tips

  • Use Azure App Service or Container Apps
  • Set secrets in Azure Portal β†’ App Configuration
  • Enable Managed Identity for secure token storage
  • For staging, use different JWT keys to prevent token bleed across environments
None

βœ… Your backend is now cloud- and container-ready.

πŸ§… GitHub Gist & Onion Architecture (Minimal API Aligned)

πŸ› οΈ Scalable, Secure JWT Auth with Minimal API + Onion Layers in .NET 8

You want startup simplicity, but without spaghetti. βœ… That's why we combine Minimal API for the surface with Onion Architecture underneath.

Here's what it looks like:

🧱 Folder & Layer Overview

JwtAuthOnion/
β”‚
β”œβ”€β”€ JwtAuth.API/                    # πŸ”Ή Presentation Layer (Minimal API)
β”‚   └── Program.cs                  # Entry point
β”‚   └── DependencyInjection.cs      # Centralized DI registration
β”‚   └── AuthEndpoints.cs            # Clean route mapping
β”‚
β”œβ”€β”€ JwtAuth.Application/            # πŸ”Έ Application Layer (Core Logic)
β”‚   └── Interfaces/
β”‚       └── ITokenService.cs
β”‚       └── IRefreshTokenStore.cs
β”‚   └── Services/
β”‚       └── TokenService.cs
β”‚   └── DTOs/
β”‚       └── AuthResult.cs
β”‚       └── LoginRequest.cs
β”‚       └── RefreshRequest.cs
β”‚
β”œβ”€β”€ JwtAuth.Domain/                 # πŸ”Έ Domain Layer (Entities & Business Rules)
β”‚   └── User.cs
β”‚
β”œβ”€β”€ JwtAuth.Infrastructure/         # πŸ”Ή Infrastructure Layer (Data Access / External I/O)
β”‚   └── Stores/
β”‚       └── InMemoryRefreshTokenStore.cs
β”‚       └── RedisRefreshTokenStore.cs
β”‚
β”œβ”€β”€ JwtAuth.Tests/                  # πŸ§ͺ Test Project
β”‚   └── TokenServiceTests.cs
β”‚
└── JwtAuth.sln

βœ… Minimal API Surface

// In Program.cs

app.MapPost("/login", AuthEndpoints.Login);
app.MapPost("/refresh", AuthEndpoints.Refresh);
app.MapGet("/profile", AuthEndpoints.Profile).RequireAuthorization();

✨ Benefits of This Architecture

  • 🧠 Minimal API is fast, clean, and easy to test.
  • πŸ§… Onion layers separate business logic from infrastructure.
  • πŸ”Œ DI config is centralized β€” easy to swap stores (InMemory ↔ Redis).
  • πŸ”„ Testable services with no dependency on EF or ASP.NET Core.
None

βœ… Stay Connected. Build Better.

πŸš€ Cut the noise. Write better systems. Build for scale. 🧠 Real-world insights from a senior engineer shipping secure, cloud-native systems since 2009.

πŸ“© Want more? Subscribe for sharp, actionable takes on modern .NET, microservices, and DevOps architecture.

πŸ”—R Lt's connect: πŸ’Ό LinkedIn β€” Tech insights, career reflections, and dev debates πŸ› R️ GitHub β€” Production-ready patterns & plugin-based architecture tools 🀝R Upwork β€” Need a ghost architect? Let's build something real.

β˜• Support My Work

If this breakdown sparked better decisions or inspired cleaner code architecture, you can fuel my next deep dive with a warm mug of support: πŸ‘‰ Buy Me a Coffee β˜•

πŸ”§ Your coffee keeps my terminal glowing, πŸ’‘ My ideas flowing, and πŸ”₯ My passion for clean code alive.

Much appreciated, one sip at a time. πŸš€πŸ‘¨β€πŸ’»πŸ’œ