CORS : le guide complet du videur d'Internet que personne n'a invité
Ou : Comment un mécanisme de sécurité inventé pour vous protéger est devenu l’erreur la plus googlée par les développeurs web
Vous développez une application. Le frontend tourne sur localhost:3000, l’API sur localhost:8080. Vous faites un fetch() vers votre propre API. Et le navigateur vous répond :
Access to fetch at 'http://localhost:8080/api/users' from origin
'http://localhost:3000' has been blocked by CORS policy.
Votre première réaction : quelque chose est cassé. Votre deuxième réaction : chercher « disable CORS » sur Google. Votre troisième réaction, si vous lisez cet article : comprendre que CORS n’est pas le problème. CORS est la solution. Le problème, c’est ce qui se passerait sans lui.
CORS, c’est le videur d’une boîte de nuit1. Vous arrivez à la porte, il vérifie si votre nom est sur la liste. Si oui, vous entrez. Si non, vous restez dehors — peu importe que vous soyez le propriétaire, le DJ, ou un client fidèle. Le videur ne connaît pas le contexte. Il connaît la liste. Et c’est exactement ce qui fait sa valeur.
Comment fonctionne CORS : le videur, la liste et la poignée de main secrète
Le problème originel : la Same-Origin Policy
Avant de comprendre CORS, il faut comprendre ce qu’il assouplit. Les navigateurs appliquent une règle fondamentale appelée Same-Origin Policy (SOP) : une page web ne peut faire des requêtes qu’à sa propre origine.
Une origine, c’est la combinaison de trois éléments : le protocole (https), le domaine (api.example.com), et le port (443). Si un seul de ces trois éléments diffère, c’est une autre origine.
| Depuis | Vers | Même origine ? |
|---|---|---|
https://site.be | https://site.be/api | Oui |
https://site.be | http://site.be | Non (protocole) |
https://site.be | https://api.site.be | Non (sous-domaine) |
https://site.be | https://site.be:8080 | Non (port) |
http://localhost:3000 | http://localhost:8080 | Non (port) |
La dernière ligne explique pourquoi votre environnement de développement vous harcèle avec des erreurs CORS. Deux ports différents sur localhost = deux origines différentes. Le navigateur ne fait pas de favoritisme.
Pourquoi cette règle existe ? Imaginez un monde sans SOP. Vous visitez site-malveillant.com, et le JavaScript de cette page fait un fetch('https://votre-banque.be/api/transfert', {method: 'POST', body: '...'}). Si vous êtes connecté à votre banque (cookie de session actif), la requête part avec vos identifiants. Sans SOP, n’importe quel site pourrait agir en votre nom sur n’importe quel autre site. C’est exactement l’attaque appelée CSRF (Cross-Site Request Forgery), et la SOP est le premier rempart contre elle.
CORS : le mécanisme qui ouvre des portes contrôlées
La Same-Origin Policy est efficace, mais trop stricte pour le web moderne. Votre frontend sur app.example.com a besoin de parler à votre API REST sur api.example.com. Un site a besoin de charger des polices depuis Google Fonts, des images depuis un CDN, des données depuis une API tierce.
CORS (Cross-Origin Resource Sharing) est le mécanisme qui permet au serveur de dire : « cette origine a le droit de m’interroger ». C’est une liste VIP, et le serveur la gère via des headers HTTP spécifiques.
Le principe est simple :
- Le navigateur envoie une requête cross-origin
- Le serveur répond avec des headers CORS qui indiquent ce qui est autorisé
- Le navigateur vérifie les headers — si l’origine est autorisée, il transmet la réponse au JavaScript ; sinon, il la bloque
Point crucial : c’est le navigateur qui applique CORS, pas le serveur. Le serveur répond toujours — il ne bloque rien. C’est le navigateur qui regarde les headers de la réponse et décide si le JavaScript a le droit de la lire. Un curl en ligne de commande ne verra jamais d’erreur CORS, parce que curl n’est pas un navigateur et se moque des politiques de sécurité web.
C’est pour ça que « disable CORS » est une fausse solution. Vous ne désactivez pas la sécurité du serveur — vous désactivez celle du navigateur. En production, vos utilisateurs auront toujours un navigateur.
Les headers CORS : la liste VIP du serveur
Toute la mécanique CORS repose sur quelques headers HTTP. Voici les principaux :
| Header | Rôle | Exemple |
|---|---|---|
Access-Control-Allow-Origin | Origines autorisées | https://app.example.com ou * |
Access-Control-Allow-Methods | Méthodes HTTP permises | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | Headers personnalisés autorisés | Content-Type, Authorization |
Access-Control-Allow-Credentials | Cookies autorisés ? | true |
Access-Control-Max-Age | Durée du cache preflight (secondes) | 86400 |
Access-Control-Expose-Headers | Headers lisibles par le JavaScript | X-Request-Id |
Le plus important est Access-Control-Allow-Origin. Il peut contenir une origine précise (https://app.example.com) ou le wildcard * (tout le monde). Mais attention : * et credentials: true sont incompatibles. Si vous envoyez des cookies, vous devez lister explicitement l’origine. Le navigateur refuse de transmettre des credentials à un serveur qui dit « tout le monde peut entrer ». La sécurité a des principes.
Le preflight : la poignée de main avant la vraie requête
Pour les requêtes « simples » (GET, POST avec Content-Type standard, sans headers personnalisés), le navigateur envoie directement la requête et vérifie les headers CORS dans la réponse.
Mais pour tout le reste — PUT, DELETE, headers comme Authorization, Content-Type: application/json — le navigateur envoie d’abord une requête preflight. C’est une requête OPTIONS qui demande au serveur : « est-ce que j’ai le droit de faire ce que je m’apprête à faire ? »
Concrètement, avant votre PUT /api/users/42 avec un header Authorization, le navigateur envoie :
OPTIONS /api/users/42 HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type
Le serveur répond :
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: Authorization, Content-Type
Access-Control-Max-Age: 86400
Le navigateur lit la réponse, confirme que tout est autorisé, et ensuite seulement envoie la vraie requête PUT. Si le preflight échoue, la vraie requête ne part jamais.
Le header Access-Control-Max-Age permet de cacher le résultat du preflight. Sans lui, chaque requête non-simple déclenche un aller-retour OPTIONS supplémentaire — ce qui double le nombre de requêtes HTTP2. En production, un Max-Age de 86400 (24 heures) est courant.
Les erreurs CORS les plus courantes (et comment les résoudre)
Si vous développez pour le web, vous allez rencontrer des erreurs CORS. Voici le top 5, avec les causes et les solutions :
1. « No ‘Access-Control-Allow-Origin’ header is present »
Le serveur ne renvoie pas le header. Solution : configurer votre serveur ou framework pour ajouter les headers CORS. En Express.js, c’est app.use(cors()). En Django, c’est django-cors-headers. Chaque framework a son middleware.
2. « The value of ‘Access-Control-Allow-Origin’ header must not be the wildcard ‘*’ when credentials mode is ‘include’ »
Vous envoyez credentials: 'include' dans votre fetch(), mais le serveur répond Access-Control-Allow-Origin: *. Solution : remplacer * par l’origine exacte du frontend.
3. « Method PUT is not allowed by Access-Control-Allow-Methods »
Le preflight a réussi, mais la méthode demandée n’est pas dans la liste. Solution : ajouter la méthode dans Access-Control-Allow-Methods.
4. « Request header field Authorization is not allowed »
Le header Authorization n’est pas dans Access-Control-Allow-Headers. Solution : l’ajouter côté serveur.
5. L’erreur silencieuse : le preflight qui échoue sans message clair
Parfois, le serveur ne gère pas du tout les requêtes OPTIONS et retourne un 404 ou un 405. Le navigateur affiche une erreur CORS générique. Solution : vérifier que votre serveur répond aux OPTIONS sur les routes concernées.
Dans tous les cas, les outils de développement du navigateur (onglet Network) sont votre meilleur ami. Cherchez la requête OPTIONS, regardez les headers de réponse. Le problème est toujours dans les headers.
CORS en production : les bonnes pratiques
Le développement local est indulgent. La production ne l’est pas. Quelques règles :
N’utilisez jamais * en production avec des credentials. C’est techniquement interdit par la spécification, mais même sans credentials, un wildcard sur une API qui gère des données sensibles est un risque. Listez explicitement les origines autorisées.
Gérez les origines dynamiquement. Si votre API sert plusieurs frontends (web, mobile, staging), ne codez pas les origines en dur. Lisez le header Origin de la requête, vérifiez-le contre une liste blanche, et renvoyez-le dans Access-Control-Allow-Origin. Un seul header, une seule origine à la fois — la spécification ne permet pas de lister plusieurs origines3.
Mettez un Access-Control-Max-Age généreux. Chaque preflight est une requête HTTP supplémentaire. En production, 86400 secondes (24 heures) réduit considérablement le trafic OPTIONS.
Ne désactivez pas CORS pour « simplifier ». Le nombre de tutoriels qui suggèrent de désactiver CORS ou d’utiliser un proxy pour « contourner le problème » est alarmant. CORS existe pour protéger vos utilisateurs. Le contourner, c’est retirer le videur de la boîte de nuit parce que la file d’attente est trop longue.
Testez depuis un vrai navigateur. curl et Postman ne déclenchent pas CORS. Votre code peut marcher dans Postman et échouer dans Chrome. Ce n’est pas un bug — c’est le fonctionnement normal.
Récapitulatif
| Terme | En une phrase |
|---|---|
| CORS | Mécanisme qui permet au serveur d’autoriser des requêtes cross-origin |
| Same-Origin Policy | Règle du navigateur : une page ne peut interroger que sa propre origine |
| Origine | Protocole + domaine + port — si un seul diffère, c’est cross-origin |
| Preflight | Requête OPTIONS automatique avant les requêtes non-simples |
| Access-Control-Allow-Origin | Header qui liste les origines autorisées |
| Access-Control-Allow-Methods | Header qui liste les méthodes HTTP permises |
| Access-Control-Allow-Headers | Header qui autorise les headers personnalisés |
| Access-Control-Allow-Credentials | Header qui autorise l’envoi de cookies |
| Access-Control-Max-Age | Durée du cache preflight, en secondes |
| CSRF | Attaque que la Same-Origin Policy contribue à prévenir |
CORS est l’un de ces mécanismes que chaque développeur web rencontre, maudit, contourne, puis finit par comprendre. Et une fois qu’on le comprend, on réalise qu’il fait exactement ce qu’il devrait faire : protéger les utilisateurs contre des requêtes qu’ils n’ont jamais autorisées, en forçant les serveurs à déclarer explicitement à qui ils veulent parler. C’est un videur qui ne connaît pas le contexte, qui ne fait pas de favoritisme, et qui ne prend pas de pots-de-vin. Dans un monde où les navigateurs exécutent du code de sources inconnues à chaque page chargée, c’est exactement le genre de rigidité dont on a besoin.
L’analogie du videur fonctionne mieux qu’il n’y paraît. Un videur ne décide pas qui peut entrer — il applique la liste du propriétaire (le serveur). Il ne fouille pas les gens (il ne modifie pas les requêtes). Et surtout, il n’existe qu’à la porte (le navigateur). Si vous passez par la porte de service (curl, Postman, un autre serveur), il n’est tout simplement pas là. C’est exactement la réalité de CORS : une protection côté client, invisible côté serveur. ↩︎
Le coût des preflight est un sujet sous-estimé. Sur une application SPA (Single Page Application) qui fait des dizaines de requêtes API avec des headers
Authorization, chaque requête sans cache preflight déclenche un aller-retour OPTIONS supplémentaire. À 50ms de latence par requête, ça s’additionne vite. C’est pourquoiAccess-Control-Max-Agen’est pas un luxe — c’est une nécessité de performance. Certains navigateurs plafonnent cependant la valeur (Chrome à 7200 secondes, soit 2 heures), même si vous demandez plus. ↩︎C’est l’une des limitations les plus surprenantes de la spécification CORS : le header
Access-Control-Allow-Originn’accepte qu’une seule valeur — soit une origine exacte, soit*. Pas de liste, pas de patterns, pas de regex. Si votre API doit servirapp.example.cometstaging.example.com, vous devez lire le headerOriginde chaque requête, le comparer à votre liste blanche, et renvoyer dynamiquement la bonne valeur. Chaque framework a ses propres solutions, mais au niveau HTTP, c’est une origine ou rien. ↩︎