Comment j’ai construit une authentification de bout en bout avec Symfony, JWT, des refresh tokens et un BFF

Un flux d’authentification complet, du formulaire de connexion typé à Symfony Security, avec rotation des refresh tokens, sessions web côté serveur, requêtes API protégées, déconnexion, récupération et connexion fédérée.
Vous est-il déjà arrivé d’implémenter un endpoint de connexion, de recevoir un JWT et de penser que l’authentification était terminée ?
L’endpoint n’est que le début.
Un système d’authentification complet doit déterminer où les jetons sont conservés, comment renouveler les jetons d’accès expirés, comment refuser les comptes désactivés, comment les routes protégées se comportent pendant le rendu côté serveur, ce que la déconnexion invalide et comment la récupération du mot de passe évite de révéler l’existence d’un compte.
J’ai rencontré ces questions en construisant une API Symfony consommée par des applications React. L’API est sans état, mais le navigateur a tout de même besoin d’une session sûre et pratique. Je ne voulais pas conserver de jetons à longue durée de vie dans localStorage, ni obliger chaque composant React à comprendre l’expiration des JWT.
Ma solution associe :
-
Symfony Security pour vérifier les identifiants et l’état du compte ;
-
un jeton d’accès JWT à courte durée de vie ;
-
un refresh token persistant et renouvelé par rotation ;
-
un Backend for Frontend, ou BFF, qui maintient les jetons hors du JavaScript du navigateur ;
-
un cookie opaque
HttpOnlyqui représente la session web ; -
des politiques de domaine pour l’état du compte, la robustesse du mot de passe, le verrouillage et les défis de récupération.
Dans cet article, je parcours le flux complet et montre comment reproduire cette architecture dans une application Symfony et React générique.
L’authentification est un flux, pas un endpoint
Le parcours complet d’une connexion par mot de passe ressemble à ceci :
Trois préoccupations distinctes apparaissent ici :
-
Authentification : qui effectue la requête ?
-
Continuité de session : comment l’application mémorise-t-elle cette identité en toute sécurité ?
-
Autorisation : que cette identité a-t-elle le droit de faire ?
Les séparer empêche une session d’interface pratique de devenir par inadvertance l’autorité de sécurité.
Pourquoi placer un BFF entre le navigateur et Symfony
Une application monopage pure conserve souvent le jeton d’accès en mémoire ou dans localStorage, puis l’ajoute aux requêtes API.
localStorage est simple à utiliser, mais tout code JavaScript exécuté sur la même origine peut le lire. Une attaque cross-site scripting réussie peut exfiltrer le jeton d’accès comme le refresh token à longue durée de vie.
J’utilise donc un BFF :
Le navigateur conserve uniquement un identifiant de session imprévisible dans un cookie HttpOnly. Le BFF stocke le JWT et le refresh token dans un enregistrement de session côté serveur.
Comme le cookie est HttpOnly, le JavaScript du navigateur ne peut pas le lire. Comme le JWT n’a jamais besoin d’entrer dans le JavaScript de l’application, les composants React ne deviennent pas responsables de la gestion des jetons.
Cela ne supprime pas la nécessité d’une protection contre les attaques XSS : un attaquant peut encore agir depuis le navigateur de la victime. Cette architecture rend toutefois le vol direct des jetons plus difficile.
Étape 1 : définir un contrat de connexion sûr pour les types
Le navigateur valide le formulaire de connexion avec Zod :
Je valide également la réponse d’authentification au lieu d’imposer son type par assertion :
export const sessionUserSchema = z.object({
userId: z.string().uuid(),
email: z.email(),
roles: z.array(z.string()),
status: z.string(),
emailVerified: z.boolean(),
passwordChangeRequired: z.boolean(),
});
export const loginResponseSchema = z
.object({
token: z.string().min(1),
refreshToken: z.string().min(1),
user: sessionUserSchema,
})
.transform((response) => ({
accessToken: response.token,
refreshToken: response.refreshToken,
user: response.user,
}));
export type LoginSession = z.infer<typeof loginResponseSchema>;Le frontend dispose maintenant d’une validation à l’exécution et d’un type TypeScript inféré. Si la réponse Symfony change de manière inattendue, l’authentification échoue à la frontière réseau au lieu de créer une session partiellement valide.
Étape 2 : transmettre les identifiants par une fonction serveur
Le navigateur n’appelle pas Symfony directement. Il invoque une fonction serveur appartenant à l’application web :
export const loginAction = createServerFn({ method: "POST" })
.inputValidator(loginSchema)
.handler(async ({ data }) => authWorkflow.login(data));Le workflow traduit le contrat du frontend dans les noms de champs attendus par l’authenticator json_login de Symfony :
async function login(data: LoginPayload) {
const response = await axios.post(
`${apiBaseUrl()}/identity/auth/login`,
{
username: data.email,
password: data.password,
},
{
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Client-Platform": "main",
},
},
);
const loginSession = loginResponseSchema.parse(response.data);
return sessionStore.persist(loginSession);
}Le navigateur transmet une adresse e-mail et un mot de passe. Le BFF reçoit les jetons de Symfony, les conserve côté serveur et renvoie uniquement à la page une représentation sûre de l’utilisateur.
Les mots de passe ne sont jamais journalisés, mis en cache, ajoutés aux outils d’analyse ni copiés dans la session.
Étape 3 : configurer le firewall de connexion de Symfony
La route de connexion dispose d’un firewall sans état dédié :
# config/packages/security.yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: auto
providers:
app_user_provider:
id: App\Identity\Infrastructure\Security\SecurityUserProvider
firewalls:
login:
pattern: ^/api/identity/auth/login
stateless: true
json_login:
check_path: /api/identity/auth/login
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
user_checker: App\Identity\Infrastructure\Security\SecurityUserChecker
api:
pattern: ^/api
stateless: true
entry_point: jwt
jwt: ~
refresh_jwt:
check_path: identity.refresh_token
logout:
path: identity.invalidate_token
user_checker: App\Identity\Infrastructure\Security\SecurityUserCheckerLe firewall de connexion effectue les opérations suivantes :
-
Il lit
usernameetpassworddans le JSON. -
Il charge un utilisateur de sécurité au moyen du provider configuré.
-
Il vérifie le mot de passe avec le hasher de Symfony.
-
Il exécute les vérifications du compte.
-
Il appelle le handler de succès ou d’échec JWT.
Aucun contrôleur de connexion personnalisé n’est nécessaire pour le flux par mot de passe. La route existe comme chemin de vérification de sécurité :
# config/routes.yaml
identity.login:
path: /api/identity/auth/login
methods: [POST]Étape 4 : adapter l’utilisateur du domaine à Symfony Security
Mon User du domaine n’implémente pas les interfaces de Symfony. L’Infrastructure crée à la place une représentation de sécurité :
final readonly class SecurityUser implements
UserInterface,
PasswordAuthenticatedUserInterface
{
public function __construct(
public UserId $userId,
public EmailAddress $email,
public AccountStatus $status,
public AccountLifecycle $lifecycle,
public Roles $roles,
public ?PasswordHash $password,
public bool $emailVerified,
public bool $passwordChangeRequired,
) {}
public static function fromDomainUser(User $user): self
{
return new self(
userId: $user->id,
email: $user->email,
status: $user->status,
lifecycle: $user->lifecycle,
roles: $user->roles,
password: $user->password,
emailVerified: $user->isEmailVerified(),
passwordChangeRequired: $user->passwordChangeRequired,
);
}
public function getUserIdentifier(): string
{
return (string) $this->email;
}
public function getPassword(): ?string
{
return $this->password?->__toString();
}
public function getRoles(): array
{
return $this->roles->toArray();
}
public function eraseCredentials(): void {}
}Le provider charge l’utilisateur du domaine et effectue la traduction :
final readonly class SecurityUserProvider implements UserProviderInterface
{
public function __construct(
private UserRepository $users,
) {}
public function loadUserByIdentifier(string $identifier): UserInterface
{
$user = $this->users->findByEmail(new EmailAddress($identifier));
if (!$user instanceof User) {
throw new UserNotFoundException();
}
return SecurityUser::fromDomainUser($user);
}
}Cet adaptateur protège le domaine de Symfony tout en laissant le framework gérer correctement l’authentification.
Étape 5 : conserver les règles d’accès au compte dans une politique
Un mot de passe correct ne suffit pas. Le compte peut ne pas être vérifié, être verrouillé, suspendu, archivé, anonymisé ou banni.
J’exprime cette décision dans une politique de domaine :
final class AccountAccessPolicy
{
public function canAuthenticate(
bool $emailVerified,
AccountStatus $status,
AccountLifecycle $lifecycle,
): bool {
if (!$emailVerified) {
return false;
}
if ($lifecycle->archived || $lifecycle->anonymized) {
return false;
}
return $status === AccountStatus::Active;
}
}Le vérificateur d’utilisateur de Symfony adapte cette politique au cycle de vie du framework :
final readonly class SecurityUserChecker implements UserCheckerInterface
{
public function __construct(
private AccountAccessPolicy $policy,
) {}
public function checkPreAuth(UserInterface $user): void
{
if (
$user instanceof SecurityUser
&& ($user->lifecycle->archived || $user->lifecycle->anonymized)
) {
throw new UserNotFoundException();
}
}
public function checkPostAuth(
UserInterface $user,
?TokenInterface $token = null,
): void {
if (
$user instanceof SecurityUser
&& !$this->policy->canAuthenticate(
$user->emailVerified,
$user->status,
$user->lifecycle,
)
) {
throw new CustomUserMessageAccountStatusException(
'Your account cannot authenticate.',
);
}
}
}Les états de compte masqués produisent le même comportement externe qu’une identité inconnue. Cela limite les informations exploitables pour énumérer les comptes.
Étape 6 : émettre des jetons d’accès courts et des refresh tokens renouvelés par rotation
J’utilise une signature JWT asymétrique :
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600
blocklist_token:
enabled: true
cache: cache.appLa clé privée signe les jetons. Les instances de l’API ont besoin de la clé publique pour les vérifier. Le jeton d’accès expire après une heure, ce qui limite la durée d’utilisation d’un jeton volé.
Les refresh tokens ont un cycle de vie différent :
# config/packages/gesdinet_jwt_refresh_token.yaml
gesdinet_jwt_refresh_token:
refresh_token_class: App\Identity\Domain\Entity\RefreshToken
ttl: 2592000 # 30 days
ttl_update: true
single_use: true
token_parameter_name: refreshTokensingle_use: true applique une rotation au refresh token. Après un renouvellement réussi, la valeur précédente ne peut plus être réutilisée.
Les deux jetons répondent à des besoins différents :
-
Le jeton d’accès est présenté fréquemment et expire rapidement.
-
Le refresh token est présenté uniquement à l’endpoint de renouvellement et possède une durée de vie plus longue.
Le refresh token doit être protégé au moins aussi soigneusement qu’un mot de passe, car il permet de créer de nouveaux jetons d’accès.
Étape 7 : enrichir la réponse de connexion avec une représentation sûre de l’utilisateur
La réponse de succès JWT par défaut contient les jetons. L’application web a également besoin d’une représentation sûre de l’utilisateur courant.
Un listener de succès d’authentification l’ajoute :
#[AsEventListener(Events::AUTHENTICATION_SUCCESS)]
final readonly class AuthenticationSuccessListener
{
public function __invoke(AuthenticationSuccessEvent $event): void
{
$user = $event->getUser();
if (!$user instanceof SecurityUser) {
return;
}
$event->setData([
...$event->getData(),
'user' => [
'userId' => (string) $user->userId,
'email' => (string) $user->email,
'roles' => $user->getRoles(),
'status' => $user->status->value,
'emailVerified' => $user->emailVerified,
'passwordChangeRequired' => $user->passwordChangeRequired,
],
]);
}
}La réponse devient :
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
"refreshToken": "4f77c2...",
"user": {
"userId": "019b...",
"email": "jane@example.com",
"roles": ["ROLE_USER"],
"status": "active",
"emailVerified": true,
"passwordChangeRequired": false
}
}N’incluez ni le hash du mot de passe, ni les indicateurs de sécurité réservés au backend, ni les données personnelles inutiles.
Cette représentation améliore le rendu de l’interface, mais ne constitue pas une source d’autorisation. Elle peut devenir obsolète. Symfony doit autoriser chaque requête API protégée à partir de l’état actuel du backend.
Étape 8 : conserver les jetons dans une session web côté serveur
Après avoir validé la réponse, le BFF crée un identifiant de session aléatoire :
type ApplicationSession = {
accessToken: string;
refreshToken: string;
user: SessionUser;
expiresAt: number;
};
async function persistSession(login: LoginSession) {
const sessionId = crypto.randomUUID();
await sessionStore.set(sessionId, {
...login,
expiresAt: Date.now() + THIRTY_DAYS,
});
setCookie("app.session", sessionId, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 30,
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
});
return login.user;
}Le cookie ne contient ni JWT ni données utilisateur. Il sert uniquement de clé de recherche.
Pour le développement local, le store de sessions peut être une map en mémoire. Les déploiements de production avec plusieurs instances ou des redémarrages nécessitent un store partagé, comme Redis ou une base de données. Sinon, une requête acheminée vers une autre instance semblera déconnectée, et chaque redémarrage de processus supprimera toutes les sessions.
Renouvelez l’identifiant de session après la connexion et les changements de privilèges pour empêcher la fixation de session. Appliquez une expiration côté serveur même lorsque le cookie possède son propre Max-Age.
Si le produit comporte des applications publiques et d’administration distinctes, utilisez des noms de cookies et des espaces de noms de sessions différents. Le partage accidentel d’une session peut brouiller les limites entre privilèges et plateformes.
Étape 9 : transmettre les requêtes API protégées par un proxy
Le navigateur appelle la route /api/* de l’application web. Le BFF transmet la requête à Symfony :
async function forwardApiRequest(request: Request, path: string) {
const session = await sessionStore.fromRequest(request);
if (!session && !isPublicPath(path)) {
return Response.json(
{ message: "Authentication required." },
{ status: 401 },
);
}
const headers = new Headers(request.headers);
// Never trust browser-supplied credentials.
headers.delete("authorization");
headers.delete("cookie");
headers.delete("host");
if (session) {
headers.set("Authorization", `Bearer ${session.accessToken}`);
}
return fetch(`${API_URL}${path}`, {
method: request.method,
headers,
body: request.method === "GET" ? undefined : await request.arrayBuffer(),
});
}Il est important de supprimer tout en-tête Authorization entrant. Le navigateur ne doit pas choisir le bearer token que le BFF de confiance transmet.
Le proxy ne doit également transmettre que les métadonnées de requête prévues. Configurez correctement les proxies de confiance avant de vous appuyer sur X-Forwarded-For ou des en-têtes similaires pour la journalisation de sécurité et la limitation du débit.
Étape 10 : renouveler une fois, appliquer la rotation et réessayer
Lorsque Symfony renvoie 401, le jeton d’accès peut avoir expiré. Le BFF échange le refresh token contre une nouvelle paire :
async function refreshSession(session: ApplicationSession) {
const response = await fetch(`${API_URL}/identity/auth/refresh-token`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ refreshToken: session.refreshToken }),
});
if (!response.ok) {
await clearSession();
return null;
}
const tokens = refreshResponseSchema.parse(await response.json());
return sessionStore.update({
...session,
accessToken: tokens.token,
refreshToken: tokens.refreshToken,
});
}Le proxy réessaie une fois la requête API initiale avec le nouveau jeton d’accès :
let response = await forwardWith(session.accessToken);
if (response.status === 401) {
const refreshed = await refreshSession(session);
if (refreshed) {
response = await forwardWith(refreshed.accessToken);
}
}
if (response.status === 401) {
await clearSession();
}
return response;Ne créez jamais de boucle de renouvellement infinie. Un renouvellement et une nouvelle tentative suffisent.
La rotation des refresh tokens introduit un problème de concurrence : plusieurs requêtes peuvent recevoir 401 au même moment et tenter de consommer le même jeton à usage unique. Utilisez un verrou single-flight propre à chaque session afin qu’une requête effectue le renouvellement pendant que les autres attendent son résultat.
Pour les requêtes qui modifient l’état, ne réessayez que si vous savez que l’authentification a rejeté la requête avant l’exécution du code métier. Utilisez des clés d’idempotence pour les commandes sensibles, comme les paiements.
Étape 11 : protéger les routes dans les deux couches
Les guards de routes du frontend améliorent la navigation :
export async function requireAuthenticatedSession(
context: { user?: SessionUser | null },
returnTo: string,
) {
if (context.user) {
return context.user;
}
throw redirect({
to: "/login",
search: { return_to: returnTo },
});
}Mais un guard React n’est pas une autorisation. Un utilisateur peut appeler l’API sans visiter la route.
Symfony protège le backend :
security:
access_control:
- { path: ^/api/identity/auth/login, roles: PUBLIC_ACCESS }
- { path: ^/api/identity/auth/refresh-token, roles: PUBLIC_ACCESS }
- { path: ^/api/identity/auth/password-reset, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }Les contrôleurs peuvent ensuite exiger des rôles métier :
#[Route('/api/admin/users', methods: ['GET'])]
#[IsGranted('ROLE_USER_MANAGER')]
final class ListUsersController
{
// ...
}La représentation conservée dans le navigateur peut masquer un élément de menu. Seul Symfony peut décider si la requête est autorisée.
Étape 12 : enregistrer les résultats de l’authentification
L’authentification est un processus métier sensible du point de vue de la sécurité. Les tentatives réussies et échouées sont donc enregistrées.
Les événements Symfony restent dans l’Infrastructure :
#[AsEventListener(LoginSuccessEvent::class)]
final readonly class LoginSuccessListener
{
public function __construct(
private CommandBus $commandBus,
) {}
public function __invoke(LoginSuccessEvent $event): void
{
$request = $event->getRequest();
$user = $event->getAuthenticatedToken()->getUser();
if (!$user instanceof SecurityUser) {
return;
}
$this->commandBus->handle(new RecordLoginSuccess(
userId: $user->userId,
identifier: new LoginIdentifier($user->getUserIdentifier()),
ipAddress: new IpAddress($request->getClientIp() ?? '127.0.0.1'),
userAgent: new UserAgent($request->headers->get('User-Agent', 'unknown')),
));
}
}Le handler applicatif persiste un LoginAttempt et met à jour la valeur lastLoginAt de l’utilisateur.
Les tentatives échouées alimentent une politique de verrouillage :
final readonly class AccountLockoutPolicy
{
public function __construct(
private int $maxFailures = 5,
private int $windowMinutes = 10,
) {}
public function shouldLock(array $attempts, DateTimeImmutable $now): bool
{
$windowStart = $now->modify(
sprintf('-%d minutes', $this->windowMinutes),
);
$failures = array_filter(
$attempts,
static fn (LoginAttempt $attempt): bool =>
$attempt->result === LoginAttemptResult::Failure
&& $attempt->occurredAt >= $windowStart,
);
return count($failures) >= $this->maxFailures;
}
}Lorsque cela est possible, journalisez les identifiants sous forme d’empreintes plutôt que d’écrire les adresses e-mail en clair dans les journaux de sécurité. Ne journalisez jamais les mots de passe, JWT, refresh tokens, jetons de réinitialisation ou codes d’autorisation OAuth.
Le verrouillage de compte peut lui-même être détourné pour provoquer un déni de service contre des utilisateurs connus. Associez-le à une limitation tenant compte de l’adresse IP, à de la supervision et à un parcours de récupération adapté au niveau de risque du produit.
Étape 13 : la déconnexion doit invalider les deux côtés
Le workflow de déconnexion du BFF envoie le refresh token à Symfony pendant que le jeton d’accès est encore disponible :
async function logout() {
const session = await sessionStore.current();
if (session) {
await api.post(
"/identity/auth/invalidate-token",
{ refreshToken: session.refreshToken },
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
},
).catch(() => null);
}
await sessionStore.clear();
deleteCookie("app.session");
}L’invalidation côté serveur traite le refresh token persistant. La liste de blocage JWT rejette le jeton d’accès courant jusqu’à son expiration naturelle. Le BFF supprime sa session et son cookie même si l’appel d’invalidation distant échoue, ce qui garantit une déconnexion locale déterministe.
La « déconnexion de tous les appareils » exige davantage que la suppression du cookie courant. Indexez les refresh tokens et les sessions BFF par utilisateur, révoquez-les tous et envisagez une version de jeton propre à l’utilisateur ou la vérification d’un claim credentialsChangedAt.
Les changements de mot de passe et la récupération après la compromission d’un compte doivent normalement révoquer les sessions existantes. Traitez ce comportement comme une politique de sécurité explicite plutôt que comme un effet secondaire accidentel.
Étape 14 : construire la récupération du mot de passe comme un défi de sécurité
La réinitialisation du mot de passe n’est pas un endpoint spécial et non authentifié de mise à jour du mot de passe. C’est un défi lié à un objectif précis, avec une date d’expiration et un usage unique.
L’endpoint de demande renvoie toujours la même réponse d’acceptation :
public function requestPasswordReset(EmailAddress $email): void
{
$user = $this->users->findByEmail($email);
if (!$user instanceof User) {
return;
}
$this->challenges->invalidateExisting(
$user->id,
SecurityChallengePurpose::ResetPassword,
);
$challenge = SecurityChallenge::issue(
userId: $user->id,
plainToken: $this->tokens->generate(),
purpose: SecurityChallengePurpose::ResetPassword,
expiresAt: $this->clock->now()->modify('+15 minutes'),
);
$this->challenges->save($challenge);
$this->notifications->sendPasswordReset($user, $challenge);
}Les adresses e-mail connues et inconnues sont impossibles à distinguer de l’extérieur. L’endpoint doit également limiter le débit et peut utiliser une vérification humaine à la frontière HTTP.
La base de données conserve uniquement un hash du jeton de défi :
final readonly class TokenHash
{
public static function fromPlainToken(string $token): self
{
return new self(hash('sha256', $token));
}
}Le jeton brut existe uniquement le temps de transmettre le lien. Il n’est jamais persisté ni journalisé.
Lorsque l’utilisateur transmet un nouveau mot de passe, le handler charge le défi à partir du hash du jeton et de l’objectif attendu, vérifie son expiration, le consomme, valide la politique de mot de passe, hache le mot de passe et persiste les deux changements dans une même transaction.
L’usage unique et le lien avec un objectif précis sont essentiels. Un jeton de vérification d’adresse e-mail ne doit pas être accepté comme jeton de réinitialisation du mot de passe.
Étape 15 : étendre les mêmes frontières à la connexion fédérée
La connexion avec Google, Apple ou Microsoft modifie la manière dont l’identité est prouvée, mais elle doit produire la même session applicative.
Le flux de redirection a besoin d’une valeur d’état signée et à courte durée de vie qui contient :
{
"provider": "google",
"mode": "login",
"expiresAt": 1780000000,
"nonce": "random-value"
}Lors du callback, vérifiez :
-
la signature de l’état ;
-
l’expiration ;
-
le provider attendu ;
-
le mode de connexion ou d’association de compte ;
-
l’identité fournie par le provider et l’adresse e-mail vérifiée.
Après avoir associé l’identité du provider à un utilisateur local, exécutez la même AccountAccessPolicy que pour l’authentification par mot de passe. Un utilisateur verrouillé ne doit pas contourner les restrictions du compte en choisissant une connexion sociale.
Le callback peut émettre les mêmes jeton d’accès, refresh token et représentation de l’utilisateur que la connexion par mot de passe.
Dans une architecture BFF, la transmission la plus sûre après une redirection repose sur un code d’autorisation à courte durée de vie et à usage unique, que le BFF échange de serveur à serveur. Évitez de placer des refresh tokens à longue durée de vie dans les paramètres de requête. Un fragment d’URL empêche le jeton d’atteindre le serveur HTTP et le référent, mais il reste visible pour le JavaScript et les extensions du navigateur. Il doit donc être supprimé immédiatement après utilisation.
Étape 16 : tester l’ensemble du système
L’authentification exige des tests à plusieurs niveaux.
Tests du domaine
Testez la robustesse du mot de passe, l’accès au compte, les fenêtres de verrouillage, l’expiration des défis, leur lien avec un objectif précis et leur usage unique à l’aide d’une horloge contrôlable.
Tests applicatifs
Exécutez les commandes de tentative de connexion et de récupération au moyen du véritable bus de commandes et de dépôts en mémoire. Parmi les scénarios utiles :
Scenario: Hiding whether a password reset account exists
When a password reset is requested for "missing@example.test"
Then the password reset request should be accepted
And no password reset challenge should be announcedScenario: Locking an account after repeated failures
Given an active user exists
When 5 failed logins occur inside 10 minutes
Then the user should be lockedTests fonctionnels Symfony
Appelez les véritables endpoints HTTP et vérifiez que :
-
des identifiants valides produisent un jeton d’accès et un refresh token ;
-
des identifiants invalides produisent une réponse d’échec générique ;
-
les comptes non vérifiés, verrouillés, archivés et anonymisés sont refusés ;
-
un jeton d’accès valide ouvre un endpoint protégé ;
-
un jeton expiré peut être renouvelé une fois ;
-
un refresh token remplacé par rotation ne peut pas être rejoué ;
-
la déconnexion invalide les deux jetons.
Tests du BFF et du frontend
Vérifiez que la connexion persiste une session serveur, que le cookie ne contient aucun jeton, qu’un 401 déclenche un seul renouvellement, qu’un échec du renouvellement supprime la session et que les guards de routes conservent un chemin de retour validé.
Testez également les requêtes expirées concurrentes. La rotation du refresh token fonctionne souvent dans un test à requête unique, puis échoue dans les conditions de concurrence réelles d’un navigateur.
Erreurs courantes d’authentification
Stocker les refresh tokens dans le stockage local
Un refresh token est un identifiant à longue durée de vie. Conservez-le dans une session côté serveur ou dans un cookie HttpOnly configuré avec soin, selon l’architecture et le modèle de menaces.
Faire confiance à l’objet utilisateur du frontend
La représentation de l’utilisateur sert au rendu. Elle ne doit jamais remplacer l’autorisation du backend.
Renouveler indéfiniment
Réessayez au maximum une fois. Si le renouvellement échoue, supprimez la session et exigez une nouvelle authentification.
Utiliser un store de sessions en mémoire dans une production à plusieurs instances
La mémoire d’un processus n’est pas partagée et disparaît au redémarrage. Utilisez Redis ou un autre store partagé et durable qui prend en charge l’expiration.
Considérer SameSite comme une protection CSRF complète
SameSite=Lax est utile, mais évaluez les sous-domaines d’un même site, le comportement des navigateurs et les fonctions serveur qui modifient l’état. Ajoutez des vérifications de l’origine ou des jetons CSRF lorsque le modèle de menaces l’exige.
Révéler l’existence d’un compte
Les messages de connexion, d’inscription et de réinitialisation du mot de passe peuvent créer des canaux d’énumération. Conservez des erreurs publiques génériques et comparez les différences de temps de réponse lorsque le risque le justifie.
Oublier la concurrence lors du renouvellement
Les refresh tokens à usage unique nécessitent un verrou de renouvellement par session. Sinon, des requêtes parallèles peuvent consommer le même jeton et déconnecter l’utilisateur de manière imprévisible.
Journaliser des secrets
Masquez Authorization, les cookies, les mots de passe, les refresh tokens, les liens de réinitialisation, les codes OAuth et les valeurs d’état signées dans les journaux de l’application et les outils d’observabilité.
Conclusion
L’authentification de bout en bout repose sur la collaboration entre le navigateur, le serveur web, Symfony Security, le domaine et la persistance.
-
Zod valide les identifiants et les réponses à la frontière du frontend.
-
Le BFF maintient les jetons d’accès et les refresh tokens hors du stockage du navigateur.
-
Le firewall de connexion de Symfony vérifie les identifiants.
-
Un adaptateur de sécurité maintient les interfaces du framework hors de l’utilisateur du domaine.
-
Les politiques de domaine déterminent si le compte peut s’authentifier.
-
Les jetons d’accès à courte durée de vie protègent les requêtes API.
-
Les refresh tokens à usage unique appliquent une rotation à l’identifiant de session.
-
Le BFF effectue un renouvellement unique et réessaie de manière transparente.
-
Symfony reste l’autorité pour chaque décision d’autorisation.
-
La déconnexion invalide les identifiants distants et supprime la session locale.
-
Les défis de sécurité fournissent une récupération avec expiration et liée à un objectif précis.
-
Les enregistrements d’audit et les politiques de verrouillage rendent l’authentification observable et défendable.
La décision de conception la plus importante n’oppose pas les JWT aux cookies. Elle consiste à déterminer quel composant assume chaque responsabilité et à garantir qu’aucun raccourci pratique côté client ne devienne une frontière de sécurité.
L’authentification n’est complète que lorsque la connexion, la continuité, l’expiration, la révocation, la récupération et l’autorisation forment un ensemble cohérent.
Bon code !