Comprendre CORS une bonne fois pour toutes
Les erreurs CORS apparaissent dans la console au pire moment, n'indiquent presque rien d'utile sur la façon de les corriger, et sont « résolues » en copiant-collant Access-Control-Allow-Origin: * depuis Stack Overflow sans que personne comprenne ce qui vient de se passer. Voici ce qui se passe réellement, et pourquoi le navigateur vous fait ça exprès.
Ce n'est pas un problème de serveur, c'est un problème de navigateur
La première chose à intégrer : CORS (Cross-Origin Resource Sharing) est appliqué entièrement par le navigateur, pas par le serveur. Si vous faites un curl sur un endpoint qui « a une erreur CORS » dans le navigateur, cela fonctionne très bien, car curl n'applique pas CORS du tout. Le serveur envoie toujours la réponse ; c'est le navigateur qui décide s'il laisse votre JavaScript la lire.
Cela existe à cause de la same-origin policy : par défaut, un script exécuté sur https://app.example.com ne peut pas lire les réponses de https://api.other.com, même s'il peut techniquement envoyer la requête. Sans cela, n'importe quel site visité pourrait envoyer discrètement des requêtes authentifiées à votre banque en votre nom, avec les cookies que votre navigateur envoie automatiquement. CORS est le mécanisme qui permet à un serveur d'autoriser explicitement certaines autres origines à lire ses réponses.
Les origines, précisément
Deux URL partagent la même origine uniquement si le schéma, l'hôte et le port correspondent exactement.
https://app.example.com:443/page1
https://app.example.com:443/page2 → same origin (path doesn't matter)
http://app.example.com → different origin (scheme differs)
https://api.example.com → different origin (host differs)
https://app.example.com:8080 → different origin (port differs)
localhost:3000 qui parle à localhost:4000 pendant le développement est une requête cross-origin, ce qui explique pourquoi les environnements de développement locaux rencontrent CORS en permanence, même si « tout est en localhost ».
Requêtes simples et requêtes avec preflight
Toutes les requêtes cross-origin ne déclenchent pas le même comportement du navigateur. Une requête est « simple » (et évite l'aller-retour supplémentaire) seulement si elle remplit des conditions assez étroites : la méthode est GET, HEAD ou POST ; seule une courte liste d'en-têtes est définie (Accept, Accept-Language, Content-Language, Content-Type) ; et Content-Type vaut application/x-www-form-urlencoded, multipart/form-data ou text/plain.
Tout le reste (un PUT, un en-tête personnalisé comme Authorization ou X-Api-Key, un corps JSON avec Content-Type: application/json) déclenche un preflight : le navigateur envoie d'abord une requête OPTIONS pour demander la permission, avant d'envoyer la vraie.
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
Le serveur doit répondre explicitement à ce preflight :
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Access-Control-Max-Age compte pour les performances : il indique au navigateur combien de secondes il peut mettre en cache ce résultat de preflight avant de redemander. Sans lui, chaque PUT vers une API JSON coûte deux requêtes au lieu d'une.
Un serveur Express minimal qui fait les choses correctement
app.use((req, res, next) => {
const allowedOrigins = ['https://app.example.com', 'http://localhost:3000'];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
L'en-tête Vary: Origin compte si cette réponse peut être mise en cache quelque part (CDN, proxy partagé) : il indique au cache que le corps de la réponse dépend de l'en-tête Origin de la requête, et qu'il ne doit donc pas servir une réponse destinée à une origine à une requête venant d'une autre.
Les identifiants sont opt-in, deux fois
Si une requête doit envoyer des cookies ou un en-tête Authorization pour une session située en cross-origin, les deux côtés doivent l'accepter explicitement :
// Client
fetch('https://api.example.com/orders', {
credentials: 'include',
});
// Server response
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Deux choses posent constamment problème : Access-Control-Allow-Origin: * est explicitement interdit par la spécification dès que des identifiants sont impliqués (il faut renvoyer une origine précise, pas un joker), et oublier Access-Control-Allow-Credentials: true côté serveur fait que le navigateur écarte silencieusement la réponse, même si la requête elle-même a réussi.
Pourquoi * n'est pas qu'un raccourci paresseux
Access-Control-Allow-Origin: * dit au navigateur « n'importe quel site d'Internet peut lire cette réponse ». Pour une API vraiment publique et sans authentification (une API météo, une ressource de CDN public), c'est correct. Pour tout ce qui renvoie des données propres à l'utilisateur ou authentifiées, cela revient à annuler tout l'objet de la same-origin policy : n'importe quel site visité par l'utilisateur pourrait lire ces données par un fetch en arrière-plan, si des identifiants étaient d'une manière ou d'une autre joints. C'est pourquoi les requêtes avec identifiants bloquent le joker au niveau de la spécification, plutôt que de compter sur les développeurs pour ne pas combiner les deux.
À retenir
CORS n'est ni un bug de votre serveur ni un obstacle arbitraire dressé par le navigateur : c'est le navigateur qui exige que les lectures cross-origin soient explicitement autorisées, origine par origine, par le serveur lu. Les requêtes simples subissent une vérification d'en-têtes légère ; tout ce qui comporte un en-tête personnalisé, une méthode non triviale ou un corps JSON passe d'abord par un preflight. Si l'origine autorisée, les en-têtes autorisés et l'indicateur d'identifiants sont corrects, les erreurs disparaissent, car le navigateur n'a plus rien à objecter.