Le contexte
Sur mes projets découplés type SyllVS, le dev se fait sur deux ports : le front React servi par Vite sur localhost:5173, l'API Symfony sur localhost:8000. Deux origines différentes au sens du navigateur — et donc, dès le premier fetch vers l'API :
Access to fetch at 'http://localhost:8000/api/versements' from origin
'http://localhost:5173' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check.
Comprendre le preflight
Pour toute requête « non simple » (méthode PUT/DELETE, header Authorization ou Content-Type: application/json), le navigateur envoie d'abord une requête OPTIONS de reconnaissance. Si le serveur n'y répond pas avec les bons headers CORS, la vraie requête n'est jamais envoyée. C'est pour ça qu'on voit une requête OPTIONS en échec dans l'onglet réseau avant même son POST.
Piège classique : le preflight OPTIONS n'envoie pas le header Authorization. Si le firewall Symfony exige un JWT sur /api sans exception pour OPTIONS, le preflight prend un 401 et tout est bloqué.
La solution : nelmio/cors-bundle
composer require nelmio/cors-bundle# config/packages/nelmio_cors.yaml
nelmio_cors:
    defaults:
        origin_regex: true
        allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
        allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
        allow_headers: ['Content-Type', 'Authorization']
        expose_headers: ['Link']
        max_age: 3600
    paths:
        '^/api/': null# .env.local
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
Le max_age: 3600 dit au navigateur de mettre en cache la réponse preflight pendant une heure : une seule requête OPTIONS par heure au lieu d'une par appel API.
Le piège des credentials
Si l'authentification passe par un cookie (refresh token httpOnly par exemple), il faut deux choses côté front et côté serveur :
// cote front : envoyer les cookies cross-origin
fetch('http://localhost:8000/api/token/refresh', {
    method: 'POST',
    credentials: 'include',   // sans ca, le cookie ne part jamais
});# cote serveur
nelmio_cors:
    defaults:
        allow_credentials: true
        # ATTENTION : avec allow_credentials, le wildcard '*' est interdit
        # par la spec — il faut une origine explicite
        allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
C'est le point qui m'a coûté le plus de temps : allow_origin: ['*'] + credentials: 'include' est rejeté silencieusement par le navigateur. La spec CORS impose une origine explicite dès qu'il y a des credentials. L'erreur console ne le dit pas clairement, elle parle juste de policy failure.
Et en production ?
En prod, front et API vivent sur le même domaine derrière Apache (ou sur des sous-domaines précis). La variable d'environnement suffit à durcir :
# .env prod
CORS_ALLOW_ORIGIN='^https://(www\.)?monapp\.com$'
Jamais de regex laxiste en prod : une origine CORS trop ouverte + credentials, c'est une porte ouverte au vol de session depuis n'importe quel site.
Ce que je retiens
  • Une requête OPTIONS en échec dans l'onglet réseau = problème de preflight, la vraie requête n'est même pas partie.
  • Le preflight ne porte pas le header Authorization : le firewall ne doit jamais exiger un token sur les OPTIONS.
  • allow_credentials: true interdit le wildcard * — origine explicite obligatoire, et le navigateur échoue sans message clair sinon.
  • max_age sur le preflight économise une requête OPTIONS par appel API.
  • La config CORS vit dans une variable d'environnement : permissive en dev, stricte en prod.