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 +
Secureen 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.