Skip to content

JWT & API authentication — lexik bundle, RS256, refresh, JWKS

TL;DRlexik/jwt-authentication-bundle est la solution standard pour JWT en Symfony. HS256 (clé symétrique) suffit pour un mono-service ; RS256/ES256 (paire RSA/EC) est obligatoire pour multi-services / IdP externe. Pas de "logout JWT" : on gère la révocation via refresh tokens stockés + TTL court sur l'access token. JWKS expose les clés publiques pour validation distribuée.

Mental model — ASCII diagram + analogy

Analogie : Un JWT = passeport scellé. La signature (RS256) est le sceau du gouvernement (IdP). Tout vérificateur avec la clé publique sait que le passeport est authentique sans appeler le gouvernement.

   POST /login_check (email, password)


   ┌──────────────────────────────────────────────────────────┐
   │  JWTAuthenticator                                          │
   │   - check credentials                                      │
   │   - sign with PRIVATE KEY (RS256)                          │
   │   - return { token: "eyJhbGciOi...", refresh_token: ... }  │
   └──────────────────────────────────────────────────────────┘


   Client stores token (memory / localStorage)


   GET /api/me  with  Authorization: Bearer <token>


   ┌──────────────────────────────────────────────────────────┐
   │  Stateless firewall                                        │
   │   - parse JWT                                              │
   │   - verify signature using PUBLIC KEY                      │
   │   - check exp, iss, aud claims                             │
   │   - load user from `username_claim` (sub / email)          │
   └──────────────────────────────────────────────────────────┘

JWT structure: header.payload.signature  (base64url)

Code minimal — realistic snippet

Install

bash
composer require lexik/jwt-authentication-bundle
# RS256 keys
php bin/console lexik:jwt:generate-keypair
yaml
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'   # ssl: file://.../private.pem
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 900                                # 15 minutes
    encoder:
        signature_algorithm: RS256
    user_id_claim: sub
    user_identity_field: email
yaml
# config/packages/security.yaml (extrait)
security:
    firewalls:
        login:
            pattern: ^/api/login_check
            stateless: true
            json_login:
                check_path: /api/login_check
                username_path: email
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern: ^/api
            stateless: true
            provider: app_users
            jwt: ~

    access_control:
        - { path: ^/api/login_check, roles: PUBLIC_ACCESS }
        - { path: ^/api,             roles: ROLE_USER }
yaml
# config/routes.yaml
api_login_check:
    path: /api/login_check
    methods: [POST]

Refresh token (gesdinet bundle)

bash
composer require gesdinet/jwt-refresh-token-bundle
php bin/console doctrine:migrations:diff && php bin/console doctrine:migrations:migrate
yaml
# config/packages/gesdinet_jwt_refresh_token.yaml
gesdinet_jwt_refresh_token:
    refresh_token_class: App\Entity\RefreshToken
    ttl: 2592000        # 30 days
    single_use: true    # rotate at each refresh

# config/routes.yaml
api_token_refresh:
    path: /api/token/refresh
    methods: [POST]
    controller: gesdinet.jwtrefreshtoken::refresh

Custom JWT payload (event listener)

php
<?php
namespace App\EventListener;

use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: JWTCreatedEvent::class)]
final class JwtCreatedListener
{
    public function __invoke(JWTCreatedEvent $event): void
    {
        /** @var \App\Entity\User $user */
        $user = $event->getUser();

        $payload = $event->getData();
        $payload['uid']   = $user->getId();
        $payload['roles'] = $user->getRoles();
        $payload['org']   = $user->getOrganization()?->getSlug();

        $event->setData($payload);
    }
}

JWKS endpoint (exposer les clés publiques)

Bug classique'%kernel.project_dir%/...' n'est PAS résolu dans une chaîne PHP littérale : c'est une syntaxe de config (YAML/services), pas de PHP. Il faut injecter le chemin via %kernel.project_dir% lié à un paramètre/argument, ou lire la clé publique configurée. Ci-dessous on injecte le PEM résolu par le container.

php
<?php
namespace App\Controller;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class JwksController
{
    public function __construct(
        // %kernel.project_dir% est résolu par le container, pas par PHP.
        #[Autowire('%kernel.project_dir%/config/jwt/public.pem')]
        private readonly string $publicKeyPath,
        #[Autowire('%env(JWT_KID)%')]
        private readonly string $kid,
    ) {}

    #[Route('/.well-known/jwks.json', methods: ['GET'])]
    public function __invoke(): JsonResponse
    {
        $publicKeyPem = file_get_contents($this->publicKeyPath);
        $details = openssl_pkey_get_details(openssl_pkey_get_public($publicKeyPem));

        $response = new JsonResponse([
            'keys' => [[
                'kty' => 'RSA',
                'use' => 'sig',
                'alg' => 'RS256',
                'kid' => $this->kid,          // doit matcher le header `kid` des JWT émis
                'n'   => self::b64u($details['rsa']['n']),
                'e'   => self::b64u($details['rsa']['e']),
            ]],
        ]);

        // JWKS = quasi-statique → cache CDN agressif, mais court assez pour la rotation.
        $response->headers->set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=600');

        return $response;
    }

    private static function b64u(string $bin): string
    {
        return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
    }
}

kid (key id) est le pivot de la rotation : pendant une rotation, le JWKS expose deux clés (old + new) ; les vérificateurs choisissent par kid. Sans kid, impossible de tourner une clé sans casser tous les tokens en vol. Configurez lexik pour émettre le header kid (option additional_token_headers / kid_header selon version) et faites pointer le kid du JWKS dessus.

Anatomie d'un JWT — ce que vérifie (et ne vérifie pas) la signature

header     {"alg":"RS256","typ":"JWT","kid":"main-2026"}
payload    {"sub":"42","email":"[email protected]","iat":1718524800,"exp":1718525700,
            "iss":"https://auth.acme.io","aud":"api.acme.io","roles":["ROLE_USER"]}
signature  RSASSA-PKCS1-v1_5( SHA256( base64url(header) + "." + base64url(payload) ),
                              clé privée )

La signature garantit intégrité + authenticité : le payload n'a pas été modifié et a été émis par le détenteur de la clé privée. Elle ne garantit PAS :

  • la confidentialité — le payload est lisible par quiconque (base64url, pas chiffré). Un JWT signéchiffré. Pour chiffrer, c'est JWE, rarement utile côté API.
  • la fraîcheur — un token volé reste valide jusqu'à exp. La signature ne sait pas qu'il a fui.
  • le contexte — sans aud, un token émis pour le service A est accepté par le service B (confused deputy). Toujours valider aud ET iss.

Ordre de validation côté firewall (et où ça casse)

ÉtapeVérifieÉchec →Piège staff
1. Parseformat h.p.s, base64url valide401 malformedun body vide / Bearer null côté front
2. algalgo ∈ whitelist (RS256)401algo confusion : ne jamais accepter none, ni laisser RS256↔HS256 interchangeables
3. Signatureclé publique (ou JWKS via kid)401 invalid sigkid inconnu → refresh JWKS une fois, sinon 401
4. exp / nbf / iathorloge ± leeway401 expiredclock skew inter-services → prévoir clock_skew (~30 s)
5. iss / audmatchent ce service401souvent oublié → confused deputy
6. Charge useruser_id_claim → provider401 user not founduser supprimé mais token encore valide
7. Révocationjti/sub dans denylist ?401 revokedétape non native : à ajouter via listener (voir plus bas)

L'algo confusion (étape 2) est l'attaque historique : si un vérificateur accepte HS256 alors que le service signe en RS256, un attaquant peut signer un token HS256 en utilisant la clé publique RSA comme secret HMAC (qu'il connaît, elle est publique). D'où : whitelist d'un seul algo, jamais auto côté vérification.

Révocation — le vrai problème du JWT stateless

« Pas de logout JWT » est un raccourci. Voici l'arbre de décision réel selon le besoin de révocation :

StratégieLatence révocationCoût/reqQuand
TTL court seul (5–15 min)jusqu'à exp0 (stateless pur)défaut ; suffisant si fenêtre acceptable
Refresh rotation single-useaccess: TTL ; refresh: immédiat1 DB write au refreshrévoquer une session (mobile, SPA)
Denylist jti (Redis)immédiate1 GET Redis/reqrévoquer un token précis (bannissement, fuite)
tokenVersion sur le userimmédiatedéjà chargé via provider« déconnecter partout » (changement mdp)
Tokens introspectés (opaques)immédiate1 call/reqbesoin de contrôle total → plus du JWT, c'est de la session

Le pattern tokenVersion est sous-utilisé et élégant : on met un entier tv dans le payload, on stocke token_version sur l'entité User. Au login on copie la valeur ; pour invalider TOUS les tokens d'un user (mot de passe changé, compte compromis), on incrémente token_version en base. Un listener compare payload.tv === user.tokenVersion à chaque requête — coût nul (le user est déjà chargé).

php
<?php
namespace App\EventListener;

use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTAuthenticatedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;

#[AsEventListener(event: JWTAuthenticatedEvent::class)]
final class TokenVersionListener
{
    public function __invoke(JWTAuthenticatedEvent $event): void
    {
        $payload = $event->getPayload();
        $user = $event->getToken()->getUser();

        // $user doit exposer getTokenVersion(): int
        if (($payload['tv'] ?? -1) !== $user->getTokenVersion()) {
            throw new CustomUserMessageAuthenticationException('Token revoked');
        }
    }
}

Patterns courants — 3–6 patterns

  1. Access court + refresh long : access TTL 15 min, refresh 30 j stocké DB → révocation possible côté serveur.
  2. Rotation single-use des refresh tokens : un refresh = un nouveau couple ; ancien invalidé immédiatement.
  3. JWKS pour services tiers : les microservices vérifient les JWT en téléchargeant la JWKS (cache 1h). Pas de secret partagé.
  4. Claims custom signées : org id, plan, locale dans le payload pour éviter une requête DB à chaque appel.
  5. OAuth2 + OpenID Connect via Keycloak / Auth0 : Symfony devient juste un resource server, valide les JWT IDP signés.
  6. Audit on every request : event listener JWTAuthenticatedEvent pour logguer (user, ip, route).

Versions — Symfony 5.4 / 6.4 / 7.x

Topic5.46.47.x
lexik bundle2.x3.x3.x
jwt: ~ shortcutOK (5.3+)OKOK
LexikJWTAuthenticationBundle\Security\Http\Authenticator\JWTAuthenticatorAuthenticator new systemIdemIdem
firebase/php-jwt underlying5.x6.x6.x
JWTTokenAuthenticator (Guard, legacy)DeprecatedRetiréRetiré
gesdinet refresh bundle1.x1.2+1.3+
Argument resolver pour #[CurrentUser]OKOKOK
  • lexik 3 drop PHP 7, ajoute auto algo, web-token JWS framework supporté.
  • firebase/php-jwt 6 a renforcé la validation des claims exp/nbf.

OAuth2 vs JWT (résumé senior)

  • JWT = format de token (signé, autocontenu).
  • OAuth2 = protocole d'autorisation (avec ses flows : code, client credentials…).
  • OpenID Connect = surcouche OAuth2 qui standardise l'authentification, retourne un ID Token en JWT.

Donc on dit "stack OAuth2 + OIDC qui émet des JWT". Lexik en Symfony peut être le resource server qui consomme les JWT émis par Keycloak (OIDC provider), pas l'IDP lui-même.

Pitfalls — 5–8 concrete traps

  1. alg: none accepté : version ancienne de php-jwt acceptait none. Toujours imposer alg: RS256 ou whitelist explicite.
  2. HS256 partagé : utiliser HS256 entre plusieurs services = secret dupliqué = fuite garantie. RS256 obligatoire.
  3. Stocker JWT dans localStorage : exploitable par XSS. Préférer cookie HttpOnly + SameSite=strict, ou mémoire JS courte avec refresh.
  4. TTL trop long : access 24h = impossible à révoquer. Garder 5–30 min max.
  5. Pas de exp / nbf strict check : faille d'horloge entre services → tokens "non encore valides" rejetés ou expirés acceptés.
  6. Logout côté serveur impossible : le JWT est valide jusqu'à exp. Sans liste de révocation, vous ne pouvez pas le tuer. Refresh tokens permettent au moins de rejeter la rotation.
  7. PII dans le payload : tout JWT est lisible (juste base64). Pas de données sensibles : email peut passer, mot de passe non, numéro carte non.
  8. JWKS sans cache : chaque requête microservice → HTTP call vers IDP. Cache 1h obligatoire avec invalidation sur kid inconnu.
  9. stateless: false sur firewall API : Symfony crée une session inutile → perf détruite.

Testing — phpunit / KernelTestCase

php
<?php
namespace App\Tests\Api;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class ApiAuthTest extends WebTestCase
{
    public function testMeEndpointRequiresValidJwt(): void
    {
        $client = static::createClient();
        $client->request('GET', '/api/me');
        self::assertResponseStatusCodeSame(401);
    }

    public function testMeEndpointWithGeneratedJwt(): void
    {
        $client = static::createClient();
        $container = static::getContainer();

        $user = $container->get(EntityManagerInterface::class)
            ->getRepository(User::class)->findOneBy(['email' => '[email protected]']);

        $jwt = $container->get(JWTTokenManagerInterface::class)->create($user);

        $client->request('GET', '/api/me', server: [
            'HTTP_AUTHORIZATION' => 'Bearer ' . $jwt,
        ]);

        self::assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        self::assertSame('[email protected]', $data['email']);
    }
}

🎬 Cas d'usage concrets

Scénario 1 — API bancaire ouverte aux agrégateurs avec JWT et scopes

La néobanque expose une API DSP2 vers 14 agrégateurs partenaires (Bridge, Powens, Tink) leur permettant d'accéder, sur consentement client, à la liste des comptes et aux opérations de 90 jours glissants. Chaque consentement délivre un JWT signé RS256 (clé asymétrique stockée dans Vault, rotation tous les 90 jours) contenant les claims sub (client id), scopes (accounts:read, transactions:read), consent_id, exp (90 jours). L'authenticator JWT valide la signature, la exp, et l'absence du jti dans une liste de révocations Redis (alimentée par l'écran de retrait de consentement client). Un voter ScopeVoter est consulté à chaque endpoint : GET /accounts exige accounts:read, GET /transactions exige transactions:read. Les tokens sont à durée longue mais peuvent être révoqués en < 100 ms grâce à Redis. L'audit log enregistre chaque appel avec le consent_id pour répondre aux contrôles ACPR.

Scénario 2 — API mobile e-commerce avec refresh token rotation

Une marketplace e-commerce de mode dispose d'une app mobile iOS et Android avec 1,2 million d'utilisateurs actifs mensuels. L'authentification émet deux tokens : un JWT d'accès courte durée (15 minutes) et un refresh token long (30 jours) stocké côté client dans le Keychain/Keystore et côté serveur dans Postgres avec hash SHA-256 (le refresh token n'apparaît jamais en clair en base). À chaque rafraîchissement, le refresh token est invalidé et un nouveau émis (rotation), ce qui détecte un vol de refresh : si un token déjà utilisé est rejoué, toute la famille de refresh est révoquée et l'utilisateur est forcé à se reconnecter. Les JWT contiennent un device_id permettant de tracer les sessions multi-devices. Le déploiement utilise lexik/jwt-authentication-bundle côté backend et un wrapper natif côté mobile gérant le retry automatique sur 401. Cette architecture a permis de réduire de 60 % les pop-ups de réauthentification tout en améliorant la sécurité.

Scénario 3 — API SaaS RH ouverte à partenaires via clés API et JWT à la volée

Un SaaS RH français expose une API REST à ses partenaires intégrateurs (paie, formation, badgeuse). Chaque partenaire reçoit une api_key longue durée stockée hashée en base. À chaque appel, le partenaire envoie sa clé sur un endpoint /oauth/token, qui émet un JWT courte durée (1 heure) listant les scopes effectivement provisionnés (employees:read, payroll:write, time:read), le tenant_id du client final, et un rate_limit_tier. Les endpoints API consomment le JWT classique. Un middleware applicatif extrait le rate_limit_tier du token et applique un rate-limit via Symfony RateLimiter (par exemple 100 req/min sur tier standard, 500 sur premium). Le scope payroll:write impose un IP-allowlisting supplémentaire vérifié dans un voter custom. Les clés API peuvent être rotées sans interruption grâce à une fenêtre de chevauchement de 7 jours pendant laquelle deux clés actives coexistent.

🛠️ Exemple end-to-end

Use case : émission de JWT avec scopes, voter de scope, et endpoint protégé pour l'API bancaire DSP2.

php
<?php
// src/UI/Http/Controller/Api/TokenController.php
declare(strict_types=1);

namespace App\UI\Http\Controller\Api;

use App\Domain\Consentement\Repository\ConsentementRepository;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class TokenController extends AbstractController
{
    public function __construct(
        private readonly ConsentementRepository $consentements,
        private readonly JWTTokenManagerInterface $jwt,
    ) {}

    #[Route('/oauth/token', methods: ['POST'])]
    public function emettre(Request $request): JsonResponse
    {
        $consentId = (string) $request->request->get('consent_id');
        $consent = $this->consentements->find($consentId) ?? throw $this->createNotFoundException();
        if (!$consent->estActif(new \DateTimeImmutable())) {
            return new JsonResponse(['error' => 'consent_inactive'], 400);
        }

        $token = $this->jwt->createFromPayload($consent->getClient(), [
            'consent_id' => $consent->getId(),
            'scopes' => $consent->getScopes(),
            'tenant_id' => $consent->getTenantId(),
            'jti' => bin2hex(random_bytes(16)),
        ]);

        return new JsonResponse([
            'access_token' => $token,
            'token_type' => 'Bearer',
            'expires_in' => 90 * 86400,
        ]);
    }
}

// src/Security/Voter/ScopeVoter.php
namespace App\Security\Voter;

use Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\JWTPostAuthenticationToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

/**
 * @extends Voter<string, mixed>
 */
final class ScopeVoter extends Voter
{
    protected function supports(string $attribute, mixed $subject): bool
    {
        return str_starts_with($attribute, 'SCOPE_');
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        if (!$token instanceof JWTPostAuthenticationToken) {
            return false;
        }
        $payload = $token->getPayload();
        $scopes = (array) ($payload['scopes'] ?? []);
        $needle = strtolower(substr($attribute, 6));
        return in_array($needle, $scopes, true);
    }
}

// src/UI/Http/Controller/Api/CompteController.php
namespace App\UI\Http\Controller\Api;

use App\Domain\Compte\Repository\CompteRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class CompteController extends AbstractController
{
    public function __construct(private readonly CompteRepository $comptes) {}

    #[Route('/api/v1/accounts', methods: ['GET'])]
    #[IsGranted('SCOPE_accounts:read')]
    public function liste(): JsonResponse
    {
        $user = $this->getUser();
        $items = $this->comptes->findActifsParTitulaire($user->getId());
        return new JsonResponse(['items' => array_map(fn ($c) => [
            'id' => (string) $c->getId(),
            'iban' => $c->getIban()->masked(),
            'balance' => $c->getSolde()->toCents(),
            'currency' => 'EUR',
        ], iterator_to_array($items))]);
    }
}

Observabilité & production

  • Métriques : taux de 401 par cause (exp, signature, aud, révoqué) — un pic de exp = problème d'horloge ou de TTL ; un pic invalid sig = rotation de clé ratée. Émettez un compteur par failure reason depuis le JWTFailureEventInterface.
  • Tracing : propagez sub/jti (jamais le token brut) dans les logs structurés et le trace context. Le jti permet de suivre une session/token précis dans tout le système distribué.
  • Rotation de clés : keypair → générer la nouvelle, ajouter au JWKS avec un nouveau kid, signer avec la nouvelle, garder l'ancienne en lecture le temps du TTL access max, puis retirer. Zéro downtime si kid est respecté.
  • Taille du token : chaque claim custom gonfle CHAQUE requête (header Authorization). 50 rôles dans le payload = des KB envoyés à chaque appel + risque de dépasser la limite de header du proxy (souvent 8 KB). Mettez des scopes/roles agrégés, pas la liste exhaustive.
  • Secret/clés : la clé privée et la passphrase vivent dans un secret manager (Vault, AWS Secrets Manager, Symfony Secrets vault), jamais en .env committé. La clé publique peut être publique (JWKS).

🏋️ Exercices

  1. Login + endpoint protégé (échauffement).Objectif : émettre un JWT RS256 au login_check et protéger GET /api/me en stateless. Indice : lexik:jwt:generate-keypair, firewall json_login + jwt: ~, #[CurrentUser] dans le contrôleur /api/me. Vérifier qu'un appel sans Authorization renvoie 401.

  2. Refresh rotation single-use avec détection de rejeu.Objectif : access 15 min + refresh 30 j, rotation à chaque refresh, et révocation de toute la famille si un refresh déjà consommé est rejoué. Indice : gesdinet avec single_use: true ; ajouter un champ family_id sur l'entité ; au rejeu d'un token marqué consommé, DELETE ... WHERE family_id = ?. Tester : refresh A → A', puis rejouer A → 401 + A' invalidé.

  3. Révocation immédiate par tokenVersion.Objectif : implémenter le « déconnecter partout » sans denylist ni call externe. Indice : claim tv injecté au JWTCreatedEvent, comparé au User::getTokenVersion() dans un JWTAuthenticatedEvent listener (voir code plus haut). Endpoint POST /api/account/revoke-all qui incrémente token_version. Tester qu'un JWT émis avant l'incrément est désormais 401, un émis après est OK.

  4. Scopes + voter + rate-limit par tier (production-grade).Objectif : reproduire le scénario SaaS RH : /oauth/token émet un JWT avec scopes + rate_limit_tier ; ScopeVoter garde les endpoints ; le RateLimiter applique la limite selon le tier du token. Indice : #[IsGranted('SCOPE_payroll:write')], Symfony RateLimiter avec un factory choisi par payload['rate_limit_tier']. Bonus : IP-allowlist dans le voter pour payroll:write.

  5. Break-then-fix : algo confusion.Objectif : démontrer la faille puis la corriger. Configurer un vérificateur acceptant ['RS256','HS256'], forger un token HS256 signé avec la clé publique RSA comme secret, prouver qu'il passe. Puis corriger en whitelistant RS256 seul. Indice : utiliser firebase/php-jwt en standalone pour forger : JWT::encode($payload, $publicKeyPem, 'HS256'). Le fix : signature_algorithm: RS256 strict côté lexik, jamais auto en vérification. Écrire un test qui asserte que le token forgé est rejeté.

  6. Break-then-fix : token volé via XSS + mitigation.Objectif : montrer qu'un JWT en localStorage est exfiltrable, et migrer vers cookie HttpOnly + SameSite=Strict + protection CSRF. Indice : stocker le token via set_cookie côté authentication_success_handler (cookie HttpOnly), lire le Bearer depuis le cookie via un token_extractor custom, ajouter un double-submit CSRF token pour les mutations. Tester qu'un script JS ne peut plus lire le token.

🎤 En entretien

  • « Comment révoquer un JWT avant son expiration ? » Un JWT est valide jusqu'à exp par construction. On choisit selon le besoin : TTL court (fenêtre acceptable), refresh rotation single-use (révoque une session), denylist jti en Redis (révoque un token précis), ou tokenVersion côté user (révoque tout). Si on a besoin de révocation immédiate systématique, le JWT stateless n'est peut-être pas le bon outil — des tokens opaques introspectés (= une session) le sont.

  • « RS256 ou HS256, et pourquoi ? » HS256 (symétrique) suffit pour un mono-service : un seul secret signe et vérifie. Dès qu'un autre service doit vérifier sans pouvoir émettre, il faut RS256/ES256 : la clé privée signe (IdP), la clé publique vérifie (resource servers) et se distribue via JWKS, sans jamais partager de secret. Partager un secret HS256 entre N services = N surfaces de fuite.

  • « C'est quoi l'algo confusion attack ? » Si un vérificateur accepte plusieurs algos dont HS256 et RS256, un attaquant forge un token HS256 en utilisant la clé publique RSA (qu'il connaît) comme secret HMAC. Le vérificateur, croyant valider du HMAC, recalcule la signature avec cette même clé publique et l'accepte. Mitigation : whitelist d'un seul algorithme, jamais none, jamais auto côté vérification.

  • « Où stocker le token côté client d'une SPA ? »localStorage est exposé au XSS (n'importe quel script lit le token). Cookie HttpOnly + Secure + SameSite=Strict/Lax protège du XSS mais expose au CSRF → ajouter un anti-CSRF (double-submit). Compromis fréquent : access token en mémoire JS (volatil, jamais persisté) + refresh token en cookie HttpOnly, l'app se ré-hydrate via le refresh au boot.

Quand utiliser / éviter

  • Utiliser JWT : API stateless, multi-clients (mobile/SPA), microservices.
  • Éviter JWT : app web monolithique avec cookies de session OK → garder la session, c'est plus simple, révocable, et pas plus lent.
  • Préférer cookies de session HttpOnly + SameSite pour SPA same-origin.

Liens

Bibliothèque tech perso — Achref