JWT expliqué simplement : authentification moderne
Access token, refresh token, signature — comment JWT fonctionne vraiment, où les stocker, et les erreurs de sécurité qui reviennent partout.
La première fois que j'ai implémenté une authentification JWT, j'ai stocké le token dans localStorage, mis une expiration à 30 jours, et appelé ça sécurisé. C'est l'erreur classique. Ce guide couvre la mécanique complète — et surtout les points où presque tout le monde se trompe.
JWT (JSON Web Token) est un standard ouvert (RFC 7519) pour transmettre des informations de façon vérifiable entre deux parties. Ce n'est pas un mécanisme d'authentification à proprement parler : c'est un format de token. L'authentification, c'est le système autour.
Ce que JWT remplace
Avant JWT, l'approche dominante était les sessions côté serveur : à la connexion, le serveur crée une session, stocke les données en mémoire ou en base, et renvoie un cookie avec l'ID de session. À chaque requête, le serveur lit la session en base pour identifier l'utilisateur.
Ça fonctionne bien pour un seul serveur. Ça pose des problèmes dès qu'on scale horizontalement — plusieurs instances ne partagent pas la mémoire. Solutions : sessions Redis centralisées, sticky sessions sur le load balancer. Ajout de complexité.
JWT déplace le problème : les données de l'utilisateur voyagent dans le token lui-même. Le serveur n'a rien à stocker. N'importe quelle instance peut vérifier un token en 1 ms sans aller en base.
Le compromis : les tokens JWT ne peuvent pas être révoqués facilement. Une session en base, vous la supprimez et l'utilisateur est déconnecté. Un JWT, vous ne pouvez pas le "supprimer" — il reste valide jusqu'à expiration. C'est pourquoi la durée de vie courte est critique.
Anatomie d'un JWT
Un token JWT ressemble à ça :
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiV2lsbGlhbSIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzUwMjAwMDAwLCJleHAiOjE3NTAyMDM2MDB9.xK8mP2qR9vLnT4wYjZcBsOeHfNdAuIgQkMpXCvWtRyETrois parties séparées par des points : header.payload.signature.
Header
{
"alg": "HS256",
"typ": "JWT"
}L'algorithme de signature. HS256 (HMAC-SHA256) pour une signature symétrique avec une clé secrète partagée. RS256 pour une signature asymétrique (clé privée pour signer, clé publique pour vérifier) — préférable en production quand plusieurs services vérifient les tokens.
Payload
{
"sub": "123456",
"name": "William",
"role": "user",
"iat": 1750200000,
"exp": 1750203600
}Les claims — les informations transportées. Claims standardisés :
sub— subject, l'identifiant de l'utilisateuriat— issued at, timestamp d'émissionexp— expiration timestampiss— issuer, qui a émis le tokenaud— audience, à qui le token est destiné
Claims custom autorisés : role, email, permissions, etc. Mais gardez le payload léger — il voyage dans chaque requête HTTP.
Le payload est encodé en base64, pas chiffré. N'importe qui peut le décoder. N'y mettez jamais de mot de passe, de données sensibles, d'informations bancaires.
Signature
HMACSHA256(
base64(header) + "." + base64(payload),
SECRET_KEY
)La signature garantit deux choses : que le token vient bien de votre serveur, et que le payload n'a pas été modifié en transit. Si quelqu'un change "role": "user" en "role": "admin", la signature ne correspond plus — le serveur rejette le token.
Le flux complet
Client Serveur
│ │
│── POST /auth/login ──────────►│
│ { email, password } │ Vérifier credentials
│ │ Générer access_token (15 min)
│ │ Générer refresh_token (7 jours)
│◄── 200 OK ─────────────────── │
│ { access_token, │
│ refresh_token } │
│ │
│── GET /api/profile ──────────►│
│ Authorization: Bearer <at> │ Vérifier signature
│ │ Vérifier expiration
│◄── 200 OK ─────────────────── │
│ { user data } │
│ │
│ [access_token expiré] │
│ │
│── POST /auth/refresh ────────►│
│ { refresh_token } │ Vérifier refresh_token
│ │ Émettre nouveau access_token
│◄── 200 OK ─────────────────── │
│ { access_token } │Access Token : court et ciblé
L'access token est le token de travail. Il prouve l'identité de l'utilisateur auprès des APIs protégées. Sa durée de vie doit être courte — 15 minutes à 1 heure maximum.
import jwt from 'jsonwebtoken'
function generateAccessToken(user) {
return jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
)
}function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token manquant' })
}
const token = authHeader.slice(7)
try {
const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET)
req.user = payload
next()
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expiré', code: 'TOKEN_EXPIRED' })
}
return res.status(401).json({ error: 'Token invalide' })
}
}Le code d'erreur TOKEN_EXPIRED distinct permet au client de déclencher automatiquement le refresh.
Refresh Token : persistance et rotation
L'access token expire vite — trop vite pour que l'utilisateur se reconnecte à chaque fois. Le refresh token résout ça : il vit plus longtemps (7 jours, 30 jours) et sert uniquement à obtenir un nouvel access token.
function generateRefreshToken(user) {
return jwt.sign(
{ sub: user.id }, // Payload minimal — pas besoin de plus
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
)
}Rotation des refresh tokens
Le pattern sécurisé : à chaque utilisation du refresh token, on en émet un nouveau et on invalide l'ancien.
app.post('/auth/refresh', async (req, res) => {
const { refresh_token } = req.cookies // Lire depuis cookie httpOnly
if (!refresh_token) {
return res.status(401).json({ error: 'Refresh token manquant' })
}
let payload
try {
payload = jwt.verify(refresh_token, process.env.JWT_REFRESH_SECRET)
} catch {
return res.status(401).json({ error: 'Refresh token invalide ou expiré' })
}
// Vérifier que ce token est encore en base (pas révoqué, pas déjà utilisé)
const stored = await db.refreshTokens.findOne({
token: refresh_token,
userId: payload.sub,
revoked: false,
})
if (!stored) {
// Token réutilisé — possible vol. Révoquer toute la famille.
await db.refreshTokens.updateMany({ userId: payload.sub }, { revoked: true })
return res.status(401).json({ error: 'Refresh token révoqué' })
}
// Rotation : invalider l'ancien, émettre un nouveau
await db.refreshTokens.update({ id: stored.id }, { revoked: true })
const user = await db.users.findById(payload.sub)
const newAccessToken = generateAccessToken(user)
const newRefreshToken = generateRefreshToken(user)
await db.refreshTokens.create({ token: newRefreshToken, userId: user.id })
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
})
res.json({ access_token: newAccessToken })
})Si un refresh token déjà utilisé réapparaît, c'est le signal d'un vol probable — on révoque toute la famille de tokens pour cet utilisateur, les forçant à se reconnecter.
Sécurité : où stocker les tokens
C'est le point où 80% des implémentations déraillent.
Access token → mémoire JavaScript
Pas dans localStorage, pas dans sessionStorage. Ces deux stockages sont accessibles par n'importe quel script JS sur la page — une attaque XSS les vide en une ligne.
Stockez l'access token en mémoire (variable JavaScript, contexte React, Redux store). Il disparaît au rechargement de page — c'est voulu. Au rechargement, le client utilise le refresh token pour en obtenir un nouveau.
// En mémoire — pas de persistance
let accessToken = null
async function login(email, password) {
const res = await fetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
credentials: 'include', // Pour recevoir le cookie
})
const data = await res.json()
accessToken = data.access_token // En mémoire
}
async function fetchWithAuth(url) {
if (!accessToken) {
await refreshAccessToken()
}
return fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` }
})
}Refresh token → cookie httpOnly
Un cookie httpOnly est inaccessible au JavaScript. Seul le navigateur le lit et l'envoie automatiquement. Une attaque XSS ne peut pas l'exfiltrer.
// Côté serveur — émettre le refresh token en cookie httpOnly
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // Inaccessible au JS
secure: true, // HTTPS uniquement
sameSite: 'strict', // Protège contre CSRF
path: '/auth/refresh', // Limiter le cookie à la route refresh
maxAge: 7 * 24 * 60 * 60 * 1000,
})sameSite: 'strict' empêche le cookie d'être envoyé lors de requêtes cross-site — protection CSRF intégrée.
Attaques courantes et contre-mesures
XSS (Cross-Site Scripting)
Un script injecté lit localStorage.getItem('token') et exfiltre le token vers un serveur attaquant.
Contre-mesure : access token en mémoire, refresh token en cookie httpOnly. XSS ne peut plus rien voler d'utile.
CSRF (Cross-Site Request Forgery)
Un site malveillant déclenche une requête vers votre API depuis le navigateur de la victime — le cookie est envoyé automatiquement.
Contre-mesure : sameSite: 'strict' sur le cookie. Alternativement, vérifier un header X-Requested-With côté serveur (les requêtes cross-origin simples ne peuvent pas envoyer de headers custom).
Token leakage dans les logs
// DANGER — le token se retrouve dans les logs serveur, proxys, historique navigateur
fetch(`/api/user?token=${accessToken}`)
// Correct — dans le header Authorization
fetch('/api/user', {
headers: { Authorization: `Bearer ${accessToken}` }
})Ne jamais passer un token en query parameter ou dans l'URL.
Algorithme none
Certaines bibliothèques JWT acceptent "alg": "none" — un token sans signature. Un attaquant peut forger n'importe quel payload.
// Forcer l'algorithme attendu
jwt.verify(token, SECRET, { algorithms: ['HS256'] })Secret trop faible
# DANGER — secret prévisible
JWT_SECRET=secret
JWT_SECRET=1234
JWT_SECRET=myapp
# Correct — 256 bits aléatoires minimum
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Utilisez des secrets distincts pour access et refresh tokens. Compromette l'un ne compromet pas l'autre.
Checklist sécurité
| Point | Status à viser |
|---|---|
| Durée access token | ≤ 15–60 minutes |
| Durée refresh token | 7–30 jours selon l'usage |
| Stockage access token | Mémoire JS uniquement |
| Stockage refresh token | Cookie httpOnly; secure; sameSite=strict |
| Algorithme | HS256 ou RS256 — jamais none |
| Secret access | 256 bits aléatoires minimum |
| Secret refresh | Distinct du secret access |
| Rotation refresh | Oui — invalider à chaque usage |
| Révocation en base | Oui pour les refresh tokens |
| Payload | Pas de données sensibles |
| Transport | HTTPS uniquement |
| Algorithme forcé dans verify | Oui — { algorithms: ['HS256'] } |
JWT vs sessions : quand utiliser quoi
JWT n'est pas universellement supérieur aux sessions. Le choix dépend du contexte.
Sessions côté serveur sont meilleures pour :
- Applications monolithiques classiques
- Révocation immédiate nécessaire (déconnexion instantanée en cas de compromission)
- Pas de contrainte de scaling horizontal
Pour scaler les sessions sans problème de mémoire partagée, Redis comme store de sessions est la solution standard — révocation immédiate, TTL natif, compatible multi-instance.
JWT est meilleur pour :
- APIs consommées par plusieurs clients (web, mobile, services tiers)
- Architecture microservices où plusieurs services vérifient les tokens
- Contexte serverless (pas d'état serveur)
Une API REST en Go sur une architecture distribuée est un cas d'usage naturel pour JWT — chaque microservice vérifie les tokens de façon autonome avec la clé publique. Pour une API GraphQL ou REST servie par un seul backend, sessions et JWT sont comparables.
L'authentification touche aussi aux données personnelles : ce que vous stockez dans le payload JWT peut tomber sous le RGPD si vous traitez des données d'utilisateurs européens — l'email, le rôle, l'ID peuvent suffire à identifier quelqu'un.
JWT bien implémenté est robuste. JWT mal implémenté — tokens sans expiration, stockage dans localStorage, secrets faibles — ouvre des vulnérabilités réelles. La bonne nouvelle : les bonnes pratiques ne sont pas compliquées une fois qu'on a compris pourquoi elles existent.
JWT couvre l'authentification entre client et serveur. Pour les autres vecteurs d'attaque — XSS, CSRF, injection SQL — les bases de la cybersécurité pour les développeurs complète le tableau.