JWT Authentication: Security Best Practices in 2026

Secure your APIs with JWT best practices: RS256 vs HS256, HttpOnly cookies, token rotation, refresh patterns, and common vulnerabilities to avoid in 2026.

E
ECOSIRE Research and Development Team
|19 mars 202612 min de lecture2.6k Mots|

Authentification JWT : bonnes pratiques de sécurité en 2026

Les jetons Web JSON sont partout, mais la plupart des implémentations présentent au moins une faille de sécurité critique. La surface d'attaque est plus grande qu'il n'y paraît : les attaques par confusion d'algorithmes, le vol de jetons via XSS, l'absence de validation d'expiration et la mauvaise gestion des secrets font partie des vulnérabilités les plus courantes trouvées dans les systèmes de production. Réussir JWT ne consiste pas à appeler une bibliothèque et à passer à autre chose ; cela nécessite des décisions délibérées à chaque niveau.

Ce guide couvre le cycle de vie complet de la sécurité JWT — de la sélection de l'algorithme de signature et de la structure des jetons au stockage, à la rotation, à la révocation et aux modèles d'implémentation NestJS réels — afin que vous puissiez créer une authentification qui résiste réellement aux attaques.

Points clés à retenir

  • Utilisez toujours RS256 (asymétrique) pour les systèmes distribués ; HS256 uniquement lorsque le serveur API est à la fois émetteur et vérificateur
  • Stockez les jetons dans les cookies HttpOnly, Secure, SameSite=Lax — jamais dans localStorage ou sessionStorage
  • Validez toujours les revendications exp, iss, aud et alg — ne faites jamais confiance à l'algorithme none non signé
  • Implémenter la rotation des jetons de rafraîchissement : chaque rafraîchissement émet une nouvelle paire et invalide l'ancien jeton de rafraîchissement
  • Gardez le TTL du jeton d'accès court (15 minutes) ; utiliser des jetons d'actualisation opaques stockés dans la base de données
  • Ne stockez jamais de données sensibles (mots de passe, SSN, informations de paiement) dans les charges utiles JWT — les charges utiles sont en base64, non cryptées
  • Implémenter la révocation du jeton via une liste de refus Redis ou un compteur de version dans la base de données
  • Enregistrez toutes les émissions de jetons et les événements d'actualisation pour les pistes d'audit de sécurité

Structure et revendications JWT

Un JWT comporte trois parties : l'en-tête, la charge utile et la signature, séparées par des points et codées en base64url.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc0MjM0NTYwMCwiZXhwIjoxNzQyMzQ2NTAwLCJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIn0.
[signature]

Charge utile décodée :

{
  "sub": "user_123",
  "email": "[email protected]",
  "role": "admin",
  "iat": 1742345600,
  "exp": 1742346500,
  "iss": "https://api.example.com",
  "aud": "https://app.example.com"
}

Allégations requises pour la production :

  • sub — identifiant d'utilisateur unique (jamais d'e-mail seul — les e-mails changent)
  • exp — horodatage d'expiration (toujours requis)
  • iat — horodatage émis à (détecter le décalage d'horloge)
  • iss — URL de l'émetteur (à valider par rapport à votre émetteur attendu)
  • aud — public (valider pour empêcher la réutilisation des jetons entre les services)
  • jti — ID JWT (unique par jeton, permet une révocation exacte)

RS256 vs HS256 : quel algorithme utiliser

Il s'agit de la décision de sécurité la plus importante dans la configuration JWT.

HS256 (HMAC-SHA256) — Symétrique

// Both signing and verifying require the same secret
const token = jwt.sign(payload, process.env.JWT_SECRET, { algorithm: 'HS256' });
const verified = jwt.verify(token, process.env.JWT_SECRET);

À utiliser uniquement lorsque : le service qui signe les jetons est le même service qui les vérifie. HS256 convient aux API monolithiques mais est dangereux dans les microservices : tout service capable de vérifier les jetons peut également les créer.

RS256 (RSA-SHA256) — Asymétrique

// Sign with private key (only the auth server holds this)
const privateKey = fs.readFileSync('/secrets/jwt-private.pem');
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', keyid: 'key-2026-01' });

// Verify with public key (any service can do this safely)
const publicKey = fs.readFileSync('/secrets/jwt-public.pem');
const verified = jwt.verify(token, publicKey, {
  algorithms: ['RS256'], // NEVER omit this — prevents algorithm confusion
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com',
});

Générez une paire de clés RSA de production :

# Generate 4096-bit RSA private key
openssl genrsa -out jwt-private.pem 4096

# Extract public key
openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem

Rotation des clés : utilisez keyid (kid) dans l'en-tête. Publiez les clés publiques actuelles sur /.well-known/jwks.json. Les services mettent en cache le JWKS et récupèrent un kid inconnu.


Stockage des cookies HttpOnly

Ce n’est pas négociable. Le stockage des jetons dans localStorage ou sessionStorage les rend accessibles à tout JavaScript exécuté sur la page, y compris les scripts injectés par les attaques XSS.

// NestJS: set HttpOnly cookie after authentication
@Post('login')
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
  const { accessToken, refreshToken } = await this.authService.login(dto);

  const cookieBase = {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax' as const,
    path: '/',
    domain: process.env.COOKIE_DOMAIN, // '.example.com' for subdomain sharing
  };

  res.cookie('access_token', accessToken, {
    ...cookieBase,
    maxAge: 15 * 60 * 1000, // 15 minutes
  });

  res.cookie('refresh_token', refreshToken, {
    ...cookieBase,
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
    path: '/auth/refresh', // Scope refresh token to only the refresh endpoint
  });

  return { message: 'Login successful' };
}
// Extract token from cookie in JWT strategy
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (req) => req?.cookies?.access_token, // Cookie first
        ExtractJwt.fromAuthHeaderAsBearerToken(), // Bearer fallback for API clients
      ]),
      secretOrKey: configService.get('JWT_PUBLIC_KEY'),
      algorithms: ['RS256'],
      issuer: configService.get('JWT_ISSUER'),
      audience: configService.get('JWT_AUDIENCE'),
    });
  }

  async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
    // Always check token version against database
    const user = await this.usersService.findById(payload.sub);
    if (!user || user.tokenVersion !== payload.tokenVersion) {
      throw new UnauthorizedException('Token invalidated');
    }
    return { id: payload.sub, email: payload.email, role: payload.role };
  }
}

Actualiser la rotation des jetons

N’émettez jamais de jeton d’accès de longue durée. Au lieu de cela, gardez les jetons d’accès de courte durée et faites pivoter les jetons d’actualisation à chaque utilisation.

// auth.service.ts
@Injectable()
export class AuthService {
  async refreshTokens(refreshToken: string, ipAddress: string) {
    // 1. Look up the refresh token in the database
    const storedToken = await this.db.query.refreshTokens.findFirst({
      where: and(
        eq(refreshTokens.token, this.hashToken(refreshToken)),
        eq(refreshTokens.revoked, false),
        gt(refreshTokens.expiresAt, new Date())
      ),
      with: { user: true },
    });

    if (!storedToken) {
      // Possible reuse attack — revoke all tokens for this user
      if (storedToken?.userId) {
        await this.revokeAllUserTokens(storedToken.userId);
        await this.alertService.send({
          message: `Refresh token reuse detected for user ${storedToken.userId}`,
          severity: 'high',
        });
      }
      throw new UnauthorizedException('Invalid refresh token');
    }

    // 2. Revoke the used refresh token (rotation)
    await this.db
      .update(refreshTokens)
      .set({ revoked: true, revokedAt: new Date(), revokedByIp: ipAddress })
      .where(eq(refreshTokens.id, storedToken.id));

    // 3. Issue new token pair
    const newAccessToken = this.issueAccessToken(storedToken.user);
    const newRefreshToken = await this.issueRefreshToken(
      storedToken.user.id,
      ipAddress,
      storedToken.family // Track token families for reuse detection
    );

    return { accessToken: newAccessToken, refreshToken: newRefreshToken };
  }

  private hashToken(token: string): string {
    return crypto.createHash('sha256').update(token).digest('hex');
  }
}

Le schéma de base de données pour les jetons d'actualisation :

CREATE TABLE refresh_tokens (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token       VARCHAR(64) NOT NULL UNIQUE, -- SHA256 hash of the actual token
  family      UUID NOT NULL,               -- Token family for reuse detection
  expires_at  TIMESTAMPTZ NOT NULL,
  revoked     BOOLEAN DEFAULT false,
  revoked_at  TIMESTAMPTZ,
  revoked_by_ip INET,
  created_at  TIMESTAMPTZ DEFAULT now(),
  created_by_ip INET
);

CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token   ON refresh_tokens(token);

Stratégies de révocation de jetons

Les JWT sont apatrides par conception : une fois émis, vous ne pouvez pas les « reprendre » sans infrastructure supplémentaire. Voici trois approches classées par compromis :

1. Durée de vie courte (15 minutes)

La stratégie de révocation la plus simple : les jetons d'accès expirent assez rapidement pour que leur révocation soit rarement nécessaire. Associez-le à la révocation immédiate du jeton d’actualisation dans la base de données.

2. Compteur de versions de jetons

Stockez un tokenVersion dans la table des utilisateurs. Incrémentez-le pour invalider tous les jetons existants :

// Increment version to logout all sessions
await this.db
  .update(users)
  .set({ tokenVersion: sql`token_version + 1` })
  .where(eq(users.id, userId));

// In JWT payload
const payload = {
  sub: user.id,
  tokenVersion: user.tokenVersion, // embedded at sign time
};

// In JwtStrategy.validate()
if (user.tokenVersion !== payload.tokenVersion) {
  throw new UnauthorizedException();
}

Nécessite une recherche dans la base de données par requête — acceptable pour la plupart des applications.

3. Liste de refus Redis

Pour une révocation immédiate sans recherche dans la base de données :

// Revoke specific token by JTI
async revokeToken(jti: string, expiresIn: number): Promise<void> {
  const key = `token:revoked:${jti}`;
  await this.redis.set(key, '1', 'EX', expiresIn);
}

// Check in JWT strategy before accepting
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
  const revoked = await this.redis.get(`token:revoked:${payload.jti}`);
  if (revoked) {
    throw new UnauthorizedException('Token revoked');
  }
  return this.buildUser(payload);
}

Compromis : une recherche Redis par requête, mais Redis est O(1) et inférieur à la milliseconde. Acceptable pour les points de terminaison de haute sécurité.


Vulnérabilités et atténuations courantes

Attaque de confusion d'algorithme

L'attaque alg: "none" : un attaquant supprime la signature et définit alg sur none, puis soumet une charge utile falsifiée. Les bibliothèques qui acceptent les jetons non signés accepteront n'importe quelle charge utile.

// WRONG — never do this
jwt.verify(token, secret); // Accepts alg:none if library allows it

// CORRECT — always specify algorithms explicitly
jwt.verify(token, publicKey, {
  algorithms: ['RS256'], // Whitelist only what you use
});

Injection d'en-tête JWT (jwk/jku)

Les attaquants créent un JWT avec un en-tête jku (URL JWKS) ou jwk (clé en ligne) pointant vers leur propre serveur de clés, puis signent avec leur propre clé privée. Un vérificateur vulnérable récupère les clés de l'attaquant et accepte le jeton.

// WRONG — never fetch keys from the token header
const jwksUri = decodedHeader.jku; // Attacker-controlled!

// CORRECT — always use a pinned, config-driven JWKS URI
const jwksClient = createRemoteJWKSet(new URL(configService.get('JWKS_URI')));

Faibles secrets pour HS256

Un secret ASCII de 32 caractères pour HS256 a environ 190 bits d'entropie, ce qui est insuffisant. Utilisez au moins 256 bits provenant d'une source aléatoire cryptographiquement sécurisée :

# Generate a strong HS256 secret
node -e "console.log(require('crypto').randomBytes(64).toString('base64url'))"

Configuration du module NestJS JWT

// auth.module.ts
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        privateKey: config.get('JWT_PRIVATE_KEY'),
        publicKey: config.get('JWT_PUBLIC_KEY'),
        signOptions: {
          algorithm: 'RS256',
          expiresIn: '15m',
          issuer: config.get('JWT_ISSUER'),
          audience: config.get('JWT_AUDIENCE'),
        },
        verifyOptions: {
          algorithms: ['RS256'],
          issuer: config.get('JWT_ISSUER'),
          audience: config.get('JWT_AUDIENCE'),
        },
      }),
    }),
  ],
})
export class AuthModule {}

Point de terminaison JWKS pour la distribution de clé publique

// jwks.controller.ts
@Controller('.well-known')
export class JwksController {
  @Get('jwks.json')
  @Public()
  async getJwks() {
    const publicKeyPem = this.configService.get('JWT_PUBLIC_KEY');
    // Convert PEM to JWK format using the 'jose' library
    const publicKey = await importSPKI(publicKeyPem, 'RS256');
    const jwk = await exportJWK(publicKey);

    return {
      keys: [
        {
          ...jwk,
          use: 'sig',
          alg: 'RS256',
          kid: this.configService.get('JWT_KEY_ID'), // e.g., 'key-2026-01'
        },
      ],
    };
  }
}

Mettez en cache ce point de terminaison de manière agressive : les clés publiques changent rarement. Définissez Cache-Control: public, max-age=3600.


Liste de contrôle de sécurité

Avant de déployer l'authentification JWT en production, vérifiez :

-[ ] Algorithme explicitement défini sur RS256 ou HS256 dans les appels de signature et de vérification

  • exp, iss, aud validé à chaque demande
  • Jeton d'accès TTL ≤ 15 minutes
  • Jetons stockés dans les cookies HttpOnly, Secure, SameSite=Lax
  • Actualisation de la rotation des jetons implémentée — ancien jeton révoqué à chaque utilisation
  • Actualiser le jeton stocké sous forme de hachage SHA256 dans la base de données (pas de texte en clair)
  • Revendication jti ajoutée pour une capacité de révocation ciblée
  • Liste de refus Redis vérifiée pour les points de terminaison de haute sécurité -[ ] Point de terminaison JWKS publié pour les services distribués
  • Clés privées stockées dans le gestionnaire de secrets (AWS Secrets Manager, Vault) -[ ] Procédure de rotation des clés documentée et testée
  • Tous les événements d'émission et d'invalidation de jetons enregistrés

Questions fréquemment posées

Est-il sûr de décoder un JWT sans vérifier la signature ?

Le décodage sans vérification est sûr pour la lecture de la charge utile, mais ne faites jamais confiance au contenu pour l'autorisation. Vérifiez toujours la signature avant de donner suite à des réclamations. La fonction jwt.decode() dans la plupart des bibliothèques ignore la vérification — utilisez-la uniquement pour les diagnostics ou pour lire kid à partir de l'en-tête avant de sélectionner la bonne clé pour la vérification.

Dois-je utiliser des cookies ou des en-têtes d'autorisation pour les applications de navigateur ?

Cookies HttpOnly pour les applications de navigateur, en-têtes d'autorisation pour les applications mobiles natives et appels API de serveur à serveur. Les cookies sont immunisés contre l'exfiltration XSS (JavaScript ne peut pas lire les cookies HttpOnly). Les applications mobiles ne peuvent pas utiliser efficacement les cookies et utilisent les jetons Bearer stockés dans le magasin de clés sécurisé de l'appareil.

Comment gérer l'expiration des jetons sur le frontend ?

Interceptez les réponses 401 et tentez une actualisation silencieuse avant de réessayer la demande d'origine. Dans React, utilisez un intercepteur Axios ou fetch. Si l'actualisation échoue également (expirée ou révoquée), redirigez vers la connexion. Conservez une seule promesse de rafraîchissement en cours pour éviter les tempêtes de rafraîchissements parallèles.

Quelle est la différence entre les jetons d'accès et les jetons d'actualisation ?

Les jetons d'accès sont de courte durée (15 minutes), sans état et vérifiés avec une clé publique ou un secret partagé à chaque requête API. Les jetons d'actualisation sont de longue durée (7 à 30 jours), opaques (chaînes aléatoires, pas JWT) et stockés côté serveur dans la base de données. Le point de terminaison du jeton d’actualisation est le seul endroit où les jetons d’actualisation sont utilisés : il s’agit d’un chemin de cookie étroit.

Puis-je stocker des rôles d'utilisateur dans la charge utile JWT ?

Oui, mais sachez que les rôles codés dans le jeton sont mis en cache jusqu'à l'expiration du jeton. Si vous révoquez le rôle d'administrateur d'un utilisateur, celui-ci le conserve jusqu'à l'expiration de son jeton d'accès actuel (jusqu'à 15 minutes). Pour les changements de rôle de haute sécurité, ajoutez également l'utilisateur à une liste de refus Redis ou incrémentez la version de son jeton pour forcer une réauthentification immédiate.

Comment implémenter « se souvenir de moi » avec les JWT ?

Émettez un jeton d'actualisation à durée de vie plus longue (90 jours) lorsque l'utilisateur coche « Se souvenir de moi » par rapport aux 30 jours standard. Stockez un indicateur persistent dans la ligne de la base de données des jetons d'actualisation afin de pouvoir afficher et révoquer les sessions persistantes séparément dans les paramètres de sécurité de l'utilisateur. N’étendez jamais la durée de vie du jeton d’accès – cela va à l’encontre de l’objectif.


Prochaines étapes

L'authentification JWT bien effectuée est la base de toute application Web sécurisée. De la signature RS256 au stockage des cookies HttpOnly, en passant par la rotation des jetons d'actualisation et les stratégies de révocation, les modèles de ce guide protègent vos utilisateurs contre les attaques d'authentification les plus courantes.

ECOSIRE met en œuvre une architecture d'authentification éprouvée, y compris l'intégration OIDC avec Authentik, les flux de cookies HttpOnly et la gestion des jetons basée sur Redis, dans tous nos projets backend. Découvrez nos services de développement axés sur la sécurité pour découvrir comment nous pouvons renforcer votre couche d'authentification.

E

Rédigé par

ECOSIRE Research and Development Team

Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.

Discutez sur WhatsApp