Blog
Symfony Security — Quand les firewalls et l’authentification entrent en conflit
La connexion peut fonctionner dans une zone tandis que l’API renvoie 401 ou une redirection HTML. La cause est souvent un firewall, un authenticator ou une règle access_control différent de celui attendu.
Connecté à l’administration, mais l’API renvoie 401
Prenons une application où la connexion à l’administration Symfony fonctionne : le tableau de bord et les pages HTML protégées s’ouvrent normalement. Le même navigateur envoie ensuite cette requête :
GET /api/admin/pages HTTP/1.1
Host: app.example.com
Accept: application/jsonL’API répond :
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="admin-api"
Content-Type: application/json
{"error":"authentication_required"}Ici, l’API attend un jeton Bearer, alors que la connexion à l’administration ouvre une session. Les deux requêtes viennent du même navigateur ; cela ne suffit pas à rendre cette session utilisable par l’API.
Les exemples concernent Symfony 7.4 et PHP à partir de la version 8.2. Les extraits de configuration sont incomplets et doivent être adaptés à la version installée et au modèle d’authentification de l’application.
Identifier d’abord le firewall concerné
Je commencerais par vérifier quel firewall a réellement pris en charge /api/admin/pages. Dans Symfony, un firewall regroupe les réglages d’authentification des requêtes qui lui correspondent. Le premier trouvé dans l’ordre de configuration est retenu, même si un motif plus précis apparaît ensuite.
Cet extrait incomplet présente volontairement un mauvais ordre :
security:
firewalls:
main:
pattern: ^/
lazy: true
api:
pattern: ^/api
stateless: truemain intercepte /api/admin/pages : api n’est donc pas examiné. lazy: true ne change pas ce choix. Si la séparation est voulue, le motif spécifique doit précéder le motif général. Il reste ensuite à vérifier l’authentificateur, le fournisseur d’utilisateurs et les règles d’accès associés. La permutation ne configure pas à elle seule l’authentification Bearer.
Le firewall retenu peut restaurer l’authentification depuis la session ou solliciter les authentificateurs applicables ; une requête publique peut rester anonyme. Symfony représente l’identité reconnue par un jeton de sécurité, distinct de la chaîne Bearer transmise par HTTP. Les droits peuvent être contrôlés avant le contrôleur par access_control, par des attributs comme #[IsGranted], ou aux endroits où le code demande explicitement une décision d’autorisation.
Une fois le firewall identifié, on peut vérifier ce qu’il attend du client. Voici une répartition possible :
/admin cookie de session stateful
/api Authorization: Bearer <token> statelessDans ce modèle, stateless: true empêche le firewall de l’API de restaurer l’authentification depuis la session d’administration. Cela explique le 401 si le navigateur envoie uniquement le cookie de session. Une API d’administration peut aussi utiliser la session : le préfixe /api n’impose aucun des deux modèles.
Des firewalls avec état peuvent partager un même context si la gestion des sessions et les fournisseurs d’utilisateurs sont compatibles. Tous les firewalls concernés doivent avoir stateless: false. Ce contexte de session partagé n’authentifie pas les requêtes d’un firewall sans état indépendant.
Vérifier quel authentificateur prend la requête en charge
La méthode supports() indique si un authentificateur donné doit traiter la requête. Une condition telle que str_starts_with($request->getPathInfo(), '/api') couvre aussi /apiary et ne vérifie même pas la présence de données d’authentification.
La méthode suivante appartient à un authentificateur illustratif qui traite les en-têtes Authorization fournis sous /api. Sa logique d’authentification accepte Bearer et rejette les autres schémas ou les jetons invalides. Request désigne Symfony\Component\HttpFoundation\Request ; le reste de la classe est omis :
public function supports(Request $request): bool
{
$path = $request->getPathInfo();
$isApiPath = $path === '/api' || str_starts_with($path, '/api/');
return $isApiPath && $request->headers->has('Authorization');
}false écarte cet authentificateur ; les règles d’accès de la route restent applicables. true le sélectionne pour l’authentification sans valider le jeton. Si un autre authentificateur traite un schéma Authorization différent, leurs conditions de sélection doivent distinguer ces schémas.
Une route publique peut proposer une authentification facultative. PUBLIC_ACCESS n’empêche pas un authentificateur de s’exécuter : des données invalides peuvent donc provoquer un échec d’authentification même sur cette route. Lorsque plusieurs authentificateurs sont configurés, il faut examiner les chevauchements de supports() et la gestion de leurs échecs.
Lorsqu’une requête protégée nécessite une authentification, le point d’entrée configuré indique au client comment la commencer : par exemple une redirection pour le HTML ou une réponse 401 pour l’API. Le contenu JSON dépend du contrat de l’application. Symfony 7.4 peut sélectionner automatiquement un seul point d’entrée admissible ; plusieurs candidats imposent de définir entry_point. Si un authentificateur rejette les données reçues, son gestionnaire d’échec peut répondre directement.
Quand une requête JSON reçoit du HTML
Dans le navigateur, fetch suit les redirections par défaut. Voici deux réponses distinctes d’un échange possible :
HTTP/1.1 302 Found
Location: /login
HTTP/1.1 200 OK
Content-Type: text/htmlresponse.json() tente alors de lire la page de connexion comme du JSON. L’erreur d’analyse peut masquer le problème d’authentification, même si le statut final vaut 200. Ce court exemple de diagnostic côté navigateur vérifie la réponse avant de la lire :
async function loadAdminPages() {
const response = await fetch('/api/admin/pages', {
headers: { Accept: 'application/json' },
});
if (response.redirected) {
throw new Error(
'Redirection inattendue ; vérifier l’authentification.',
);
}
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const mediaType = response.headers.get('content-type')
?.split(';')[0].trim().toLowerCase();
if (mediaType !== 'application/json') {
throw new Error(
'Une réponse application/json était attendue.',
);
}
return response.json();
}Accept demande un format ; il ne configure pas le point d’entrée Symfony. L’extrait attend application/json, ce qui nécessite un contrôle adapté si l’API utilise un autre type de média JSON. Les cookies autorisés pour la même origine sont joints par défaut, mais aucun jeton Bearer n’est fourni. L’API du scénario initial refuserait donc toujours cet appel.
response.redirected détecte une redirection après son exécution. Pour empêcher fetch de la suivre, utiliser redirect: 'error' et traiter le rejet de la promesse.
Vérifier l’identité, puis les droits
L’authentification établit l’identité de l’appelant. L’autorisation détermine s’il peut effectuer l’opération demandée. Une session ou un jeton valide ne donne pas automatiquement le droit de supprimer une page.
Comme pour les firewalls, seule la première règle correspondante de access_control s’applique. Pour une API réservée aux administrateurs, cet ordre est volontairement incorrect :
security:
access_control:
- { path: ^/api, roles: PUBLIC_ACCESS }
- { path: ^/api/admin, roles: ROLE_ADMIN }Pour /api/admin/pages, seule la règle publique est retenue. L’exigence ROLE_ADMIN prévue ne s’applique donc plus au niveau de access_control. Un attribut du contrôleur ou un contrôle explicite peut encore protéger l’opération, mais la restriction de la seconde ligne reste sans effet. La règle spécifique doit passer en premier, et l’accès anonyme être réservé aux ressources prévues pour cela.
Il faut ensuite examiner les rôles. Avec cette configuration, l’administrateur hérite aussi des droits du rédacteur :
security:
role_hierarchy:
ROLE_ADMIN:
- ROLE_EDITORElle ne donne pas les droits d’administration aux rédacteurs. Pour tenir compte de l’héritage, il faut utiliser les contrôles d’autorisation Symfony plutôt que considérer le résultat brut de getRoles() comme l’ensemble des permissions effectives.
Le rôle peut néanmoins être insuffisant. Une politique de suppression de pages pourrait exiger que l’utilisateur soit propriétaire de la page et appartienne à son équipe, et que la page relève de l’organisation courante tout en étant encore un brouillon. Un appel comme $this->denyAccessUnlessGranted('PAGE_DELETE', $page) dans le contrôleur demande cette décision pour l’objet concerné. Un voter intervient s’il prend en charge l’attribut et l’objet demandés ; la stratégie de décision et les autres voters influencent le résultat. Masquer le bouton de suppression dans React ne fait pas respecter cette politique.
Le statut se lit avec le contrat de réponse configuré :
401 Unauthorizedindique normalement l’absence de données d’authentification valides pour la ressource. HTTP impose une invitation à s’authentifier dansWWW-Authenticate; l’exemple initial utilise Bearer.403 Forbiddensignifie que le serveur refuse la requête. Ce n’est pas une preuve d’authentification : une requête anonyme, un échec CSRF ou une autre politique peuvent aussi entraîner un refus.- Une redirection vers la connexion peut être correcte pour un client HTML. Il faut examiner la réponse initiale et la chaîne de redirections, pas uniquement la dernière page ou l’erreur d’analyse JSON.
Le navigateur et le serveur Next.js n’envoient pas la même requête
Si la navigation fonctionne mais qu’une actualisation échoue, il faut comparer les lieux d’exécution des deux appels API. Un composant serveur ou un gestionnaire de route envoie sa propre requête à Symfony. La navigation avec l’App Router peut elle aussi solliciter le serveur : le mode de navigation ne suffit donc pas à situer l’exécution de fetch.
Navigateur -> Symfony
Cookies : selon leur portée et le mode credentials.
Bearer : ajouté explicitement dans Authorization.
Navigateur -> Next.js -> Symfony
Symfony ne reçoit que les données d’authentification
ajoutées par le serveur à sa propre requête.Un fetch côté serveur n’hérite ni des cookies ni de l’en-tête Authorization de la requête du navigateur ; credentials: 'include' ne les transmet pas non plus. Il faut lire le contexte entrant avec les API de la version Next.js installée et n’envoyer que les données d’authentification prévues à une destination Symfony de confiance. Recopier tous les en-têtes, accepter une destination choisie par l’appelant ou partager une réponse personnelle dans un cache commun créerait d’autres problèmes de sécurité.
Portée des cookies
Pour un appel du navigateur, vérifier le cookie réellement envoyé à l’API. Une origine se compose d’un schéma, d’un hôte et d’un port ; les cookies suivent d’autres règles de portée et ne sont pas isolés par port :
Path=/adminexclut/api. Le chemin détermine l’envoi du cookie, mais ne constitue pas une frontière d’autorisation.- Sans
Domain, le cookie est limité à l’hôte qui l’a créé. Un domaine parent autorisé étend sa portée aux sous-domaines, et élargit donc son exposition. Securelimite l’envoi aux connexions sécurisées, sous réserve des exceptions des navigateurs pour le développement local.HttpOnlyinterdit la lecture par JavaScript, mais pas l’envoi du cookie avecfetch.SameSiteconcerne les sites, pas seulement les origines. Deux sous-domaines HTTPS peuvent appartenir au même site tout en ayant des origines différentes. Un cookie intersite utilisantSameSite=NoneexigeSecure; les règles de confidentialité du navigateur peuvent néanmoins le bloquer.
Un réglage explicite SameSite=Lax permet certaines navigations intersites au niveau principal avec une méthode sûre au sens HTTP, comme l’ouverture d’un lien en GET. Cette exception ne couvre pas fetch. Strict exclut l’envoi intersite. credentials: 'include' ne contourne ni ces réglages ni les limites d’hôte et de chemin. La portée doit rester limitée aux besoins de la session, avec HTTPS en production.
Requêtes interorigines et CSRF
Supposons que le navigateur appelle https://api.example.com depuis https://frontend.example.com. Les origines diffèrent. L’authentification par cookie exige alors credentials: 'include', un cookie autorisé à être envoyé et une réponse CORS autorisant l’origine du frontend. Ces en-têtes de réponse illustrent ce cas précis :
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Credentials: true
Vary: OriginL’origine doit être vérifiée dans une liste autorisée avant d’être renvoyée ; * ne permet pas cet accès du navigateur avec données d’authentification. Si une requête préliminaire est nécessaire, sa réponse doit aussi autoriser la méthode et les en-têtes prévus, dont explicitement Authorization lorsqu’il est utilisé. Cet OPTIONS ne contient ni le cookie de session ni le jeton Bearer de la requête effective. Un 401 à ce stade peut donc bloquer l’appel API avant son envoi.
CORS régit l’accès du navigateur aux réponses d’autres origines et l’envoi des requêtes soumises à un contrôle préalable. Certaines requêtes atteignent tout de même le serveur alors que JavaScript ne peut pas lire leur réponse. L’opération effective conserve donc ses propres contrôles d’authentification et d’autorisation : CORS n’assure aucune de ces fonctions et ne restreint pas les appels entre serveurs de la même façon.
Pour les opérations modifiant l’état, la protection CSRF dépend du transport des données d’authentification. Les cookies envoyés automatiquement nécessitent une défense adaptée, par exemple des jetons CSRF validés ou un contrôle d’origine soigneusement conçu. Un en-tête Authorization: Bearer fourni explicitement modifie l’exposition au CSRF classique. Un jeton stocké et transmis dans un cookie, ou un mécanisme de repli acceptant des données d’authentification envoyées automatiquement, demande toujours une analyse. HttpOnly seul ne protège pas du CSRF. Désactiver globalement cette protection ou ouvrir CORS à toutes les origines ne corrige pas cet échec d’authentification.
Préciser ce que la déconnexion invalide
Fermer la session d’administration ne révoque pas nécessairement un jeton API émis séparément. Le supprimer de localStorage retire la copie de ce navigateur ; une autre copie peut rester valable jusqu’à son expiration ou sa révocation côté serveur, lorsque celle-ci est prise en charge.
Il faut préciser si la déconnexion ferme cette session, révoque les jetons API, empêche le renouvellement par jeton de rafraîchissement ou couvre l’ensemble. Le test doit vérifier la réponse du serveur aux anciennes données d’authentification, y compris après leur expiration.
Tester les refus et les opérations autorisées
Pour l’API Bearer de cet exemple, retenons ce contrat : lecture anonyme refusée avec 401, suppression par un rédacteur authentifié refusée avec 403, suppression par un administrateur autorisé réussie avec 204.
Les méthodes abstraites ci-dessous sont à implémenter dans une classe de test concrète ; ce ne sont pas des utilitaires existants de Symfony ou du projet. Elles doivent fournir des utilisateurs de test isolés et des jetons acceptés par le véritable authentificateur. Chaque test de suppression exige une page existante et supprimable selon les autres règles : le rédacteur n’a pas le droit requis, tandis que l’administrateur satisfait les conditions de propriété, d’organisation et d’état.
pageExists() doit relire les données persistées. Les assertions supposent une suppression immédiate ; elles sont à adapter à une suppression logique ou à un traitement en file d’attente. Utiliser uniquement des données et des moyens d’authentification de test.
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
abstract class AdminApiSecurityTestCase extends WebTestCase
{
abstract protected function editorBearerToken(): string;
abstract protected function adminBearerToken(): string;
abstract protected function deletablePageId(): int;
abstract protected function pageExists(int $pageId): bool;
public function testAnonymousUserCannotAccessAdminApi(): void
{
$client = static::createClient();
$client->request('GET', '/api/admin/pages', server: [
'HTTP_ACCEPT' => 'application/json',
]);
self::assertResponseStatusCodeSame(401);
self::assertResponseHeaderSame(
'WWW-Authenticate',
'Bearer realm="admin-api"',
);
}
public function testEditorCannotDeletePage(): void
{
$client = static::createClient();
$token = $this->editorBearerToken();
$pageId = $this->deletablePageId();
$client->request(
'DELETE',
'/api/admin/pages/' . $pageId,
server: [
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
);
self::assertResponseStatusCodeSame(403);
self::assertTrue($this->pageExists($pageId));
}
public function testAdminCanDeletePage(): void
{
$client = static::createClient();
$token = $this->adminBearerToken();
$pageId = $this->deletablePageId();
$client->request(
'DELETE',
'/api/admin/pages/' . $pageId,
server: [
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
);
self::assertResponseStatusCodeSame(204);
self::assertFalse($this->pageExists($pageId));
}
}Le test du rédacteur vérifie le refus et la conservation de la page ; celui de l’administrateur vérifie la réussite et la suppression effective. Ensemble, ils détectent un système qui refuserait tout le monde. Un 403 isolé ne montrerait pas que la règle de permission attendue a été appliquée.
Pour une API à session, utiliser un utilisateur de test avec loginUser($user, $firewallContext) et le contexte partagé réellement configuré, le cas échéant. Cet utilitaire contourne la connexion et ne fonctionne pas avec un firewall sans état ; le parcours de connexion réel doit donc avoir son propre test. Fournir des données CSRF valides là où elles sont requises, plutôt que désactiver la protection pour les tests.
Selon l’application, ajouter les jetons invalides ou expirés, la déconnexion et les accès à une autre organisation. Les cookies, CORS, CSRF et les redirections doivent aussi être vérifiés dans le navigateur : le client de test Symfony n’applique pas toutes ses politiques.
Suivre une requête dans un ordre reproductible
Pour une requête en échec, suivre cet ordre en distinguant les faits de configuration des suppositions :
- Relever la méthode, l’URL exacte, le statut,
Content-Typeet les redirections. Déterminer si la réponse vient de Symfony ou d’un proxy placé en amont. - Identifier le firewall correspondant, le contexte de session et
stateless. Examiner la configuration effective de l’environnement déployé, pas seulement un fragment YAML. - Vérifier les authentificateurs applicables, le point d’entrée et les gestionnaires d’échec. Établir si le cookie ou le jeton attendu est arrivé ; contrôler sa validité et le chargement de l’utilisateur dans un cadre de diagnostic maîtrisé.
- Identifier l’utilisateur reconnu, s’il existe, puis la première règle
access_controlet les contrôles supplémentaires du contrôleur, des voters ou des appels explicites d’autorisation. - Comparer l’appel du navigateur et celui du serveur Next.js : destination, portée des cookies et données d’authentification volontairement transmises.
Les informations Security du Symfony Profiler sont utiles dans un environnement de développement protégé. Le profiler doit rester privé. En production, les journaux peuvent contenir des métadonnées limitées : identifiant de corrélation, firewall, résultat et présence de données d’authentification. Pas de mots de passe, de valeurs de jetons, d’en-têtes cookie complets ou de secrets de session. Les captures de requêtes exportées doivent également être expurgées.
Documentation de référence
Les détails liés aux versions sont à confronter aux dépendances déployées :
- Symfony 7.4 : firewalls, authentification et hiérarchie des rôles — https://symfony.com/doc/7.4/security.html
- Contexte du firewall et configuration stateless — https://symfony.com/doc/7.4/reference/configuration/security.html
- Sélection des règles d’accès — https://symfony.com/doc/7.4/security/access_control.html
- Authentificateurs personnalisés et points d’entrée — https://symfony.com/doc/7.4/security/custom_authenticator.html et https://symfony.com/doc/7.4/security/entry_point.html
- Authentification dans les tests fonctionnels — https://symfony.com/doc/7.4/testing.html#logging-in-users-authentication
- Cookies du navigateur et CORS — https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie et https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- Détecter et bloquer les redirections fetch — https://developer.mozilla.org/en-US/docs/Web/API/Response/redirected
- En-têtes de requête Next.js — https://nextjs.org/docs/app/api-reference/functions/headers
- Défense contre le CSRF — https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- Sémantique des statuts HTTP — https://www.rfc-editor.org/rfc/rfc9110.html#name-status-codes
Avant de modifier la connexion, les permissions ou le frontend, suivre la requête HTTP en échec dans le contexte de sécurité qui la prend réellement en charge.
