JWT • Refresh Token .NET 8 React

Refresh Token + Rotation : Auth robuste en production

Finis les JWT qui durent 7 jours et les sessions impossibles à révoquer : on met en place un access token court, un refresh token stocké en DB, la rotation, la révocation, et un interceptor React propre.

🔐

Une authentification JWT “simple” marche très vite… jusqu’au jour où tu dois : révoquer un token, gérer une session, ou éviter que tes utilisateurs se fassent déconnecter au moindre 401.

On va construire un schéma réaliste, utilisé en production :

  • Access token (JWT) court : 10–20 minutes
  • Refresh token : long (7–30 jours), stocké en base
  • Rotation : chaque refresh invalide l’ancien refresh token
  • Révocation : logout, vol, ou admin action

1) Le flux complet (login → API → refresh → retry)

1) Login
   React  --(email/pass)-->  API .NET
   API   --(access JWT + refresh cookie)--> React

2) Appels API
   React  --(Authorization: Bearer ACCESS)--> API .NET

3) Access expiré → 401
   React  --(POST /auth/refresh, cookie only)--> API .NET
   API    --(nouveau access + rotation refresh)--> React
   React  --(retry la requête initiale)--> API .NET

Important : le refresh token est idéalement dans un cookie HttpOnly (non accessible en JS). Le JWT peut rester en mémoire (context) ou en storage selon ton niveau de risque.


2) Backend .NET : modèle RefreshToken (avec rotation)

On stocke le refresh token en DB pour pouvoir le révoquer et tracer les sessions. Bonus sécurité : on peut stocker un hash du token au lieu du token brut.

// Entities/RefreshToken.cs

public class RefreshToken
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid UserId { get; set; }

    // Recommandé : stocker TokenHash plutôt que Token brut
    public string TokenHash { get; set; } = string.Empty;

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public string CreatedByIp { get; set; } = string.Empty;

    public DateTime ExpiresAt { get; set; }
    public DateTime? RevokedAt { get; set; }
    public string? RevokedByIp { get; set; }

    // Rotation : on garde une trace du token remplaçant
    public string? ReplacedByTokenHash { get; set; }

    public bool IsActive => RevokedAt == null && DateTime.UtcNow < ExpiresAt;
}

Schéma DB (simple) :

RefreshTokens
- Id (GUID)
- UserId (GUID)
- TokenHash (TEXT)
- CreatedAt (DATETIME)
- CreatedByIp (TEXT)
- ExpiresAt (DATETIME)
- RevokedAt (DATETIME, nullable)
- RevokedByIp (TEXT, nullable)
- ReplacedByTokenHash (TEXT, nullable)

3) Backend .NET : config JWT (validation stricte)

// Program.cs (extrait)

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddJwtBearer(options =>
  {
      options.TokenValidationParameters = new TokenValidationParameters
      {
          ValidateIssuer = true,
          ValidateAudience = true,
          ValidateIssuerSigningKey = true,
          ValidateLifetime = true,
          ClockSkew = TimeSpan.FromMinutes(1),

          ValidIssuer = builder.Configuration["Jwt:Issuer"],
          ValidAudience = builder.Configuration["Jwt:Audience"],
          IssuerSigningKey = new SymmetricSecurityKey(
              Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
      };
  });

builder.Services.AddAuthorization();

Paramètres conseillés : Access token = 15 min, Refresh token = 14 jours.


4) Backend .NET : helpers (token random + hash + cookie)

// TokenHelpers.cs

using System.Security.Cryptography;
using System.Text;

public static class TokenHelpers
{
    public static string GenerateSecureToken(int bytes = 64)
    {
        var data = RandomNumberGenerator.GetBytes(bytes);
        return Convert.ToBase64String(data); // ou Base64Url
    }

    public static string Sha256(string input)
    {
        using var sha = SHA256.Create();
        var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
        return Convert.ToHexString(hash).ToLowerInvariant();
    }
}
// CookieHelpers.cs

public static class CookieHelpers
{
    public static void SetRefreshCookie(HttpResponse response, string refreshToken, DateTime expiresUtc)
    {
        response.Cookies.Append("refreshToken", refreshToken, new CookieOptions
        {
            HttpOnly = true,
            Secure = true,              // true en prod (HTTPS)
            SameSite = SameSiteMode.Strict,
            Expires = expiresUtc,
            Path = "/api/auth/refresh"  // limite le scope
        });
    }

    public static void ClearRefreshCookie(HttpResponse response)
    {
        response.Cookies.Delete("refreshToken", new CookieOptions
        {
            Path = "/api/auth/refresh"
        });
    }
}

5) Backend .NET : login (access + refresh cookie)

Le login renvoie un access token (dans le JSON), et place le refresh token en cookie HttpOnly.

// AuthController.cs (extrait)

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly IJwtFactory _jwt;

    public AuthController(AppDbContext db, IJwtFactory jwt)
    {
        _db = db;
        _jwt = jwt;
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login(LoginRequest dto)
    {
        // 1) Valider credentials (ex: via Identity)
        var user = await _db.Users.SingleOrDefaultAsync(x => x.Email == dto.Email);
        if (user == null) return Unauthorized();

        var ok = BCrypt.Net.BCrypt.Verify(dto.Password, user.PasswordHash);
        if (!ok) return Unauthorized();

        // 2) Access token JWT (court)
        var accessToken = _jwt.CreateAccessToken(user);

        // 3) Refresh token (long)
        var refreshToken = TokenHelpers.GenerateSecureToken();
        var refreshHash = TokenHelpers.Sha256(refreshToken);

        var rt = new RefreshToken
        {
            UserId = user.Id,
            TokenHash = refreshHash,
            CreatedByIp = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            ExpiresAt = DateTime.UtcNow.AddDays(14)
        };

        _db.RefreshTokens.Add(rt);
        await _db.SaveChangesAsync();

        // 4) Cookie HttpOnly
        CookieHelpers.SetRefreshCookie(Response, refreshToken, rt.ExpiresAt);

        return Ok(new
        {
            accessToken,
            user = new { user.Id, user.Email, user.DisplayName, user.Role }
        });
    }
}

public record LoginRequest(string Email, string Password);

6) Backend .NET : refresh avec rotation

Principe : on lit le cookie, on retrouve le token en DB, on vérifie qu’il est actif, puis on révoque l’ancien et on crée un nouveau refresh token.

// AuthController.cs (suite)

[HttpPost("refresh")]
public async Task<IActionResult> Refresh()
{
    var refreshToken = Request.Cookies["refreshToken"];
    if (string.IsNullOrWhiteSpace(refreshToken))
        return Unauthorized(new { message = "Missing refresh token" });

    var refreshHash = TokenHelpers.Sha256(refreshToken);

    var rt = await _db.RefreshTokens.SingleOrDefaultAsync(x => x.TokenHash == refreshHash);
    if (rt == null || !rt.IsActive)
        return Unauthorized(new { message = "Invalid refresh token" });

    var user = await _db.Users.FindAsync(rt.UserId);
    if (user == null) return Unauthorized();

    // Rotation
    var newRefreshToken = TokenHelpers.GenerateSecureToken();
    var newRefreshHash = TokenHelpers.Sha256(newRefreshToken);

    rt.RevokedAt = DateTime.UtcNow;
    rt.RevokedByIp = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
    rt.ReplacedByTokenHash = newRefreshHash;

    var newRt = new RefreshToken
    {
        UserId = user.Id,
        TokenHash = newRefreshHash,
        CreatedByIp = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        ExpiresAt = DateTime.UtcNow.AddDays(14)
    };

    _db.RefreshTokens.Add(newRt);
    await _db.SaveChangesAsync();

    // Nouveau cookie
    CookieHelpers.SetRefreshCookie(Response, newRefreshToken, newRt.ExpiresAt);

    // Nouveau access token
    var newAccessToken = _jwt.CreateAccessToken(user);

    return Ok(new
    {
        accessToken = newAccessToken,
        user = new { user.Id, user.Email, user.DisplayName, user.Role }
    });
}

7) Backend .NET : logout / révocation

Logout = on révoque le refresh token actuel et on supprime le cookie.

// AuthController.cs (suite)

[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
    var refreshToken = Request.Cookies["refreshToken"];
    if (!string.IsNullOrWhiteSpace(refreshToken))
    {
        var hash = TokenHelpers.Sha256(refreshToken);
        var rt = await _db.RefreshTokens.SingleOrDefaultAsync(x => x.TokenHash == hash);
        if (rt != null && rt.IsActive)
        {
            rt.RevokedAt = DateTime.UtcNow;
            rt.RevokedByIp = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
            await _db.SaveChangesAsync();
        }
    }

    CookieHelpers.ClearRefreshCookie(Response);
    return NoContent();
}

Variante “panic button” : révoquer tous les refresh tokens de l’utilisateur (déconnexion de tous les appareils).


8) Front React : Axios interceptor (refresh + retry)

Objectif : si une requête reçoit un 401, on tente une fois un refresh, puis on rejoue la requête initiale.

// api.ts (Axios instance)

import axios from "axios";

export const api = axios.create({
  baseURL: "/api",
  withCredentials: true // IMPORTANT : envoie le cookie refreshToken
});

let accessToken: string | null = null;
let isRefreshing = false;
let refreshPromise: Promise<string> | null = null;

export function setAccessToken(token: string | null) {
  accessToken = token;
}

api.interceptors.request.use((config) => {
  if (accessToken) {
    config.headers = config.headers ?? {};
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

api.interceptors.response.use(
  (res) => res,
  async (error) => {
    const original = error.config;

    // si pas 401, on remonte
    if (error.response?.status !== 401) throw error;

    // éviter boucle infinie
    if (original._retry) throw error;
    original._retry = true;

    // refresh en cours ? on attend le même refresh
    if (!isRefreshing) {
      isRefreshing = true;
      refreshPromise = api.post("/auth/refresh").then((r) => {
        const newToken = r.data.accessToken as string;
        setAccessToken(newToken);
        return newToken;
      }).finally(() => {
        isRefreshing = false;
      });
    }

    const newToken = await refreshPromise!;
    original.headers.Authorization = `Bearer ${newToken}`;

    return api.request(original);
  }
);

Côté login, tu appelles /auth/login puis tu stockes l’access token en mémoire :

// login.ts (exemple)

import { api, setAccessToken } from "./api";

export async function login(email: string, password: string) {
  const res = await api.post("/auth/login", { email, password });
  setAccessToken(res.data.accessToken);
  return res.data.user;
}

9) Checklist production (simple mais solide)

  • Access token court (10–20 min) ✅
  • Refresh token HttpOnly + Secure en prod ✅
  • Rotation : chaque refresh invalide l’ancien ✅
  • Révocation : logout + admin “revoke all” ✅
  • Hash en DB (évite fuite de tokens) ✅
  • Limiter le Path du cookie (/api/auth/refresh) ✅
  • SameSite=Strict (ou Lax selon besoins cross-site) ✅
  • Un seul retry côté React (anti boucle infinie) ✅

Conclusion

Avec ce pattern, tu obtiens une auth JWT “propre” : sessions contrôlables, refresh transparent, et un front qui ne “casse” pas au premier token expiré.

Si tu veux aller encore plus loin : ajout d’un deviceId, affichage des sessions actives (appareils) dans le profil, et un endpoint admin pour révoquer une session précise.