Aller au contenu

Le média des artisans du web lundi 14 septembre 2026

Back-end

CORS : comprendre et configurer le partage de ressources entre origines

Le partage de ressources entre origines reste une source de confusion tenace en développement web. Comprendre ce que le navigateur protège et quels en-têtes le serveur doit renvoyer évite les erreurs bloquantes comme les configurations trop permissives.

Le partage de ressources entre origines, ou CORS, reste l’une des sources de confusion les plus tenaces du développement web. Comprendre ce que le navigateur protège, et quels en-têtes le serveur doit renvoyer, évite autant les erreurs bloquantes que les configurations dangereusement permissives.

Ce que le navigateur protège vraiment

Par défaut, une page servie depuis une origine ne peut pas lire la réponse d’une requête envoyée vers une autre origine. Une origine, ce sont trois éléments : le schéma, le domaine et le port. https://app.exemple.com et https://api.exemple.com sont donc deux origines distinctes, tout comme un même domaine servi en HTTP et en HTTPS. Cette règle, la politique de même origine, empêche un site malveillant de lire les données d’un service où la victime est authentifiée.

CORS n’affaiblit pas cette protection : il fournit au serveur un moyen d’autoriser explicitement certaines origines à lire ses réponses. La requête part quand même, mais le navigateur masque la réponse au code JavaScript tant que les bons en-têtes ne l’autorisent pas. C’est une nuance décisive : CORS protège le lecteur, pas le serveur, qui doit conserver ses propres contrôles d’accès. Ce raisonnement rejoint celui appliqué au durcissement d’un socle serveur.

Requêtes simples et requêtes préparées

Le navigateur distingue deux cas. Une requête dite simple (méthode GET, HEAD ou POST, en-têtes basiques, type de contenu standard) part directement. Dès qu’une requête sort de ce cadre, par exemple un PUT, un en-tête Authorization ou un corps JSON, le navigateur envoie d’abord une requête préparée, le preflight, avec la méthode OPTIONS. Cette étape demande au serveur s’il accepte l’appel avant de l’exécuter.

OPTIONS /api/orders HTTP/1.1
Host: api.exemple.com
Origin: https://app.exemple.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.exemple.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600
Vary: Origin

Tant que le preflight n’a pas reçu de réponse favorable, la vraie requête n’est jamais émise. Une erreur CORS visible en console signale presque toujours un serveur qui répond mal à ce OPTIONS, pas un bug côté client.

Les en-têtes côté serveur

Le contrat CORS tient dans une poignée d’en-têtes de réponse. Les connaître un par un dissipe la plupart des blocages.

En-têteRôle
Access-Control-Allow-Originorigine autorisée, une seule valeur ou *
Access-Control-Allow-Methodsméthodes acceptées, en réponse au preflight
Access-Control-Allow-Headersen-têtes de requête autorisés
Access-Control-Allow-Credentialsautorise cookies et identifiants, incompatible avec *
Access-Control-Max-Agedurée de mise en cache du preflight, en secondes
Access-Control-Expose-Headersen-têtes de réponse lisibles par le JavaScript

Configurer selon la stack

La logique est identique partout : reconnaître l’origine appelante, la comparer à une liste blanche, puis renvoyer les en-têtes correspondants. Côté Node, un intergiciel suffit.

const ALLOWED = new Set([
  "https://app.exemple.com",
  "https://admin.exemple.com",
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin");
  }
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Max-Age", "600");
    return res.status(204).end();
  }
  next();
});

Le même contrat s’exprime au niveau du serveur frontal, ce qui évite de charger l’application pour de simples requêtes OPTIONS.

location /api/ {
    set $cors "";
    if ($http_origin ~* ^https://(app|admin)\.exemple\.com$) {
        set $cors $http_origin;
    }
    add_header Access-Control-Allow-Origin $cors always;
    add_header Vary Origin always;

    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Max-Age 600 always;
        return 204;
    }
    proxy_pass http://backend;
}

Cookies, identifiants et pièges classiques

Le piège le plus répandu associe Access-Control-Allow-Origin: * et l’envoi de cookies. La spécification l’interdit : dès que la requête transporte des identifiants, l’origine doit être renvoyée nommément, et Access-Control-Allow-Credentials passer à true. Renvoyer dynamiquement l’origine reçue impose alors d’ajouter Vary: Origin, sans quoi un cache pourrait servir la mauvaise autorisation à une autre origine.

Une étoile qui autorise tout le monde et des cookies dans la même réponse : la spécification refuse, et c’est une bonne nouvelle.

Deux autres confusions reviennent souvent. Un code d’erreur HTTP correct accompagné d’en-têtes CORS absents apparaît en console comme une erreur CORS, alors que le serveur a bien répondu. Et le JavaScript ne peut lire, sur une réponse cross-origin, que les en-têtes déclarés dans Access-Control-Expose-Headers, un détail qui bloque souvent la lecture d’un en-tête de pagination ou d’un jeton.

Reconstruire l’en-tête Access-Control-Allow-Origin à partir de l’origine reçue sans la valider contre une liste blanche revient à tout autoriser. Une expression régulière trop large, du type exemple.com sans ancrage, laisse passer exemple.com.attaquant.net. La liste blanche explicite reste la seule approche sûre.

Tester et déboguer sans deviner

Reproduire le comportement du navigateur en ligne de commande isole immédiatement la cause. Un appel OPTIONS forgé avec curl montre si le serveur renvoie les bons en-têtes de preflight, avant même d’ouvrir le navigateur.

# Reproduce the preflight the browser would send
curl -i -X OPTIONS https://api.exemple.com/api/orders \
  -H "Origin: https://app.exemple.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type, authorization"

# Then replay the real request and inspect its CORS headers
curl -i https://api.exemple.com/api/orders \
  -H "Origin: https://app.exemple.com"

Dans l’onglet réseau des outils de développement, une requête bloquée par CORS apparaît avec un statut mais une réponse illisible, tandis que la console détaille précisément l’en-tête manquant. Ce couple, message en console puis rejeu en curl, suffit à trancher en quelques secondes entre une erreur de configuration serveur et un véritable défaut applicatif. Un preflight qui répond correctement en curl mais échoue dans le navigateur pointe presque toujours vers un intermédiaire, proxy ou CDN, qui filtre la méthode OPTIONS ou réécrit les en-têtes.

Sous-domaines, environnements et cookies

À la question des identifiants s’ajoute l’attribut SameSite des cookies, indépendant de CORS mais souvent confondu avec lui. Un cookie posé en SameSite=Lax ne sera pas envoyé sur une requête d’arrière-plan vers un autre site, même avec une configuration CORS parfaite. Une authentification partagée entre sous-domaines distincts exige donc à la fois un cookie en SameSite=None; Secure et l’autorisation des identifiants côté serveur. Quand plusieurs environnements coexistent, préproduction et production sur des sous-domaines voisins, la liste blanche gagne à être dérivée d’une variable d’environnement plutôt que codée en dur, pour éviter qu’une URL de recette ne fuite en production.

Ce qu’il faut retenir

CORS n’est pas un obstacle à contourner mais un contrat à honorer. Le navigateur applique la politique de même origine ; le serveur choisit, en connaissance de cause, les origines qu’il autorise à lire ses réponses. Une liste blanche stricte, un preflight correctement traité, un Vary: Origin quand l’origine est dynamique, et l’immense majorité des erreurs disparaissent. La tentation du joker * ne se justifie que pour des ressources réellement publiques et sans identifiants. Pour tout le reste, nommer les origines coûte quelques lignes et protège durablement.

Sur mes projets, la première question quand une requête casse en production n’est plus « d’où vient ce bug CORS ? » mais « le serveur répond-il correctement au OPTIONS ? ». Neuf fois sur dix, la réponse est là. J’ai fini par bannir le * de mes API authentifiées et par centraliser la liste blanche dans une seule constante, relue à chaque revue. C’est moins spectaculaire qu’un correctif malin, mais ça m’évite de rouvrir le même ticket tous les trimestres. — Simon Janvier

Pour aller plus loin : la référence CORS du MDN Web Docs et la spécification Fetch du WHATWG. Côté back-end, voir aussi les évolutions récentes de Node.js et les outils en ligne de commande pratiques pour rejouer une requête préparée avec curl.

Partager LinkedIn Bluesky Hacker News E-mail

À lire aussi