JWT & API authentication — lexik bundle, RS256, refresh, JWKS
TL;DR —
lexik/jwt-authentication-bundleest 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
composer require lexik/jwt-authentication-bundle
# RS256 keys
php bin/console lexik:jwt:generate-keypair# 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# 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 }# config/routes.yaml
api_login_check:
path: /api/login_check
methods: [POST]Refresh token (gesdinet bundle)
composer require gesdinet/jwt-refresh-token-bundle
php bin/console doctrine:migrations:diff && php bin/console doctrine:migrations:migrate# 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::refreshCustom JWT payload (event listener)
<?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
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 parkid. Sanskid, impossible de tourner une clé sans casser tous les tokens en vol. Configurez lexik pour émettre le headerkid(optionadditional_token_headers/kid_headerselon version) et faites pointer lekiddu 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 valideraudETiss.
Ordre de validation côté firewall (et où ça casse)
| Étape | Vérifie | Échec → | Piège staff |
|---|---|---|---|
| 1. Parse | format h.p.s, base64url valide | 401 malformed | un body vide / Bearer null côté front |
2. alg | algo ∈ whitelist (RS256) | 401 | algo confusion : ne jamais accepter none, ni laisser RS256↔HS256 interchangeables |
| 3. Signature | clé publique (ou JWKS via kid) | 401 invalid sig | kid inconnu → refresh JWKS une fois, sinon 401 |
4. exp / nbf / iat | horloge ± leeway | 401 expired | clock skew inter-services → prévoir clock_skew (~30 s) |
5. iss / aud | matchent ce service | 401 | souvent oublié → confused deputy |
| 6. Charge user | user_id_claim → provider | 401 user not found | user supprimé mais token encore valide |
| 7. Révocation | jti/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égie | Latence révocation | Coût/req | Quand |
|---|---|---|---|
| TTL court seul (5–15 min) | jusqu'à exp | 0 (stateless pur) | défaut ; suffisant si fenêtre acceptable |
| Refresh rotation single-use | access: TTL ; refresh: immédiat | 1 DB write au refresh | révoquer une session (mobile, SPA) |
Denylist jti (Redis) | immédiate | 1 GET Redis/req | révoquer un token précis (bannissement, fuite) |
tokenVersion sur le user | immédiate | déjà chargé via provider | « déconnecter partout » (changement mdp) |
| Tokens introspectés (opaques) | immédiate | 1 call/req | besoin 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
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
- Access court + refresh long : access TTL 15 min, refresh 30 j stocké DB → révocation possible côté serveur.
- Rotation single-use des refresh tokens : un refresh = un nouveau couple ; ancien invalidé immédiatement.
- JWKS pour services tiers : les microservices vérifient les JWT en téléchargeant la JWKS (cache 1h). Pas de secret partagé.
- Claims custom signées : org id, plan, locale dans le payload pour éviter une requête DB à chaque appel.
- OAuth2 + OpenID Connect via Keycloak / Auth0 : Symfony devient juste un resource server, valide les JWT IDP signés.
- Audit on every request : event listener
JWTAuthenticatedEventpour logguer (user, ip, route).
Versions — Symfony 5.4 / 6.4 / 7.x
| Topic | 5.4 | 6.4 | 7.x |
|---|---|---|---|
| lexik bundle | 2.x | 3.x | 3.x |
jwt: ~ shortcut | OK (5.3+) | OK | OK |
LexikJWTAuthenticationBundle\Security\Http\Authenticator\JWTAuthenticator | Authenticator new system | Idem | Idem |
firebase/php-jwt underlying | 5.x | 6.x | 6.x |
JWTTokenAuthenticator (Guard, legacy) | Deprecated | Retiré | Retiré |
| gesdinet refresh bundle | 1.x | 1.2+ | 1.3+ |
Argument resolver pour #[CurrentUser] | OK | OK | OK |
- lexik 3 drop PHP 7, ajoute
autoalgo, web-token JWS framework supporté. firebase/php-jwt6 a renforcé la validation des claimsexp/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
alg: noneaccepté : version ancienne de php-jwt acceptaitnone. Toujours imposeralg: RS256ou whitelist explicite.- HS256 partagé : utiliser HS256 entre plusieurs services = secret dupliqué = fuite garantie. RS256 obligatoire.
- Stocker JWT dans localStorage : exploitable par XSS. Préférer cookie HttpOnly + SameSite=strict, ou mémoire JS courte avec refresh.
- TTL trop long : access 24h = impossible à révoquer. Garder 5–30 min max.
- Pas de
exp/nbfstrict check : faille d'horloge entre services → tokens "non encore valides" rejetés ou expirés acceptés. - 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. - 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.
- JWKS sans cache : chaque requête microservice → HTTP call vers IDP. Cache 1h obligatoire avec invalidation sur
kidinconnu. stateless: falsesur firewall API : Symfony crée une session inutile → perf détruite.
Testing — phpunit / KernelTestCase
<?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
// 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 deexp= problème d'horloge ou de TTL ; un picinvalid sig= rotation de clé ratée. Émettez un compteur parfailure reasondepuis leJWTFailureEventInterface. - Tracing : propagez
sub/jti(jamais le token brut) dans les logs structurés et le trace context. Lejtipermet 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 sikidest 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 desscopes/rolesagré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
.envcommitté. La clé publique peut être publique (JWKS).
🏋️ Exercices
Login + endpoint protégé (échauffement).Objectif : émettre un JWT RS256 au
login_checket protégerGET /api/meen stateless. Indice :lexik:jwt:generate-keypair, firewalljson_login+jwt: ~,#[CurrentUser]dans le contrôleur/api/me. Vérifier qu'un appel sansAuthorizationrenvoie 401.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 :
gesdinetavecsingle_use: true; ajouter un champfamily_idsur l'entité ; au rejeu d'un token marqué consommé,DELETE ... WHERE family_id = ?. Tester : refresh A → A', puis rejouer A → 401 + A' invalidé.Révocation immédiate par
tokenVersion.Objectif : implémenter le « déconnecter partout » sans denylist ni call externe. Indice : claimtvinjecté auJWTCreatedEvent, comparé auUser::getTokenVersion()dans unJWTAuthenticatedEventlistener (voir code plus haut). EndpointPOST /api/account/revoke-allqui incrémentetoken_version. Tester qu'un JWT émis avant l'incrément est désormais 401, un émis après est OK.Scopes + voter + rate-limit par tier (production-grade).Objectif : reproduire le scénario SaaS RH :
/oauth/tokenémet un JWT avecscopes+rate_limit_tier;ScopeVotergarde les endpoints ; le RateLimiter applique la limite selon le tier du token. Indice :#[IsGranted('SCOPE_payroll:write')], Symfony RateLimiter avec un factory choisi parpayload['rate_limit_tier']. Bonus : IP-allowlist dans le voter pourpayroll:write.Break-then-fix : algo confusion.Objectif : démontrer la faille puis la corriger. Configurer un vérificateur acceptant
['RS256','HS256'], forger un tokenHS256signé avec la clé publique RSA comme secret, prouver qu'il passe. Puis corriger en whitelistantRS256seul. Indice : utiliserfirebase/php-jwten standalone pour forger :JWT::encode($payload, $publicKeyPem, 'HS256'). Le fix :signature_algorithm: RS256strict côté lexik, jamaisautoen vérification. Écrire un test qui asserte que le token forgé est rejeté.Break-then-fix : token volé via XSS + mitigation.Objectif : montrer qu'un JWT en
localStorageest exfiltrable, et migrer vers cookieHttpOnly+SameSite=Strict+ protection CSRF. Indice : stocker le token viaset_cookiecôtéauthentication_success_handler(cookie HttpOnly), lire le Bearer depuis le cookie via untoken_extractorcustom, 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'à
exppar construction. On choisit selon le besoin : TTL court (fenêtre acceptable), refresh rotation single-use (révoque une session), denylistjtien Redis (révoque un token précis), outokenVersioncô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
HS256en 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, jamaisnone, jamaisautocôté vérification.« Où stocker le token côté client d'une SPA ? »
localStorageest exposé au XSS (n'importe quel script lit le token). CookieHttpOnly+Secure+SameSite=Strict/Laxprotè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 cookieHttpOnly, 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
- LexikJWTAuthenticationBundle — https://github.com/lexik/LexikJWTAuthenticationBundle
- gesdinet/jwt-refresh-token-bundle — https://github.com/markitosgv/JWTRefreshTokenBundle
- JWT RFC 7519 — https://datatracker.ietf.org/doc/html/rfc7519
- JWKS RFC 7517 — https://datatracker.ietf.org/doc/html/rfc7517
- "JWT best practices" — https://datatracker.ietf.org/doc/html/rfc8725
- OAuth2 vs JWT — https://oauth.net/2/