jguillaumesio
cookieslocalhostnginxhttpsdevopsfullstackdx

Partager des cookies entre sous-domaines en local

Un setup local identique à la prod où les cookies sont partagés entre sous-domaines via du vrai HTTPS, sans changer une ligne d'appli. L'astuce vit dans /etc/hosts + nginx.

Partage de cookies entre sous-domaines : prod contre setup local

1. Le problème : un env local qui ment sur les cookies

En production, une appli multi-services vit généralement sur des sous-domaines frères :

  • app.example.com, le frontend
  • api.example.com, le backend
  • auth.example.com, le service d’authentification

Ils partagent l’état de session via un cookie de domaine délimité au parent : Domain=.example.com. Le navigateur attache ensuite ce cookie à chaque sous-domaine automatiquement. Propre, standard, terminé.

En local, le setup typique est un tas de ports :

  • localhost:3000, frontend
  • localhost:4000, api

Ça semble aller, mais ça diverge discrètement de la prod de façons qui vous coûtent des heures :

  • Vous ne pouvez pas reproduire la portée du cookie de domaine parent, alors vous finissez avec un chemin d’authentification différent en local et en prod (copie manuelle de jetons, désactivation de Secure, cas particulier sur Domain).
  • Le comportement de SameSite / Secure ne peut pas être testé fidèlement parce que vous êtes en HTTP simple.
  • “Ça marche en local, ça casse en staging” devient un genre de bug récurrent.

Le but de cet article : un environnement local identique octet pour octet en comportement de cookies à la prod, sans une seule condition dans votre appli.

Note rapide sur les ports : les cookies ne sont pas isolés par port. Un cookie host-only posé par localhost:3000 est envoyé à localhost:4000, même hôte. Ça fait justement partie du problème : localhost nu soit sur-partage (tout est le même hôte) soit ne peut pas partager du tout (vous ne pouvez pas créer de portée parente). Ni l’un ni l’autre ne correspond à une vraie topologie de sous-domaines.


2. Pourquoi vous ne pouvez pas juste utiliser .localhost

L’instinct, c’est : “Je vais utiliser app.localhost et api.localhost et délimiter le cookie à .localhost.” Ça ne marche pas, et la raison est précise, pas du folklore.

Cookies host-only contre cookies de domaine

  • Pas d’attribut Domain → cookie host-only. Envoyé seulement à l’hôte exact qui l’a posé.
  • Avec attribut Domain → cookie de domaine. Envoyé à ce domaine et à tous ses sous-domaines.

Pour partager entre app.* et api.*, vous avez besoin d’un cookie de domaine délimité à leur parent commun.

La règle du Public Suffix (la vraie raison)

D’après la RFC 6265 et l’algorithme de la Public Suffix List, un navigateur rejette un cookie Domain dont la valeur est un suffixe public (un “TLD effectif”). Vous n’êtes autorisé à délimiter un cookie qu’à un domaine enregistrable (eTLD + 1) ou en dessous.

Maintenant lancez l’algorithme sur chaque candidat :

HôteTLD effectifDomaine enregistrable (eTLD+1)Pouvez-vous poser Domain=<parent> ?
app.localhostlocalhostapp.localhostDomain=localhost est un suffixe public → rejeté
app.localhost.comcomlocalhost.comDomain=localhost.com est enregistrable → accepté

Voilà toute l’astuce en un tableau : localhost est traité comme un TLD à un seul label, donc il n’y a aucune portée parente à laquelle attacher un cookie partagé. Ajoutez un vrai TLD (.com) et un parent légitime (localhost.com) apparaît.

Aparté, le TLD réservé .localhost : la RFC 6761 réserve .localhost pour toujours résoudre vers le loopback, et certains navigateurs le traitent en cas particulier. Pratique pour la résolution, inutile pour le partage de cookies : c’est toujours un seul label, donc le rejet ci-dessus s’applique encore.

Le mythe historique des “deux points” : l’ancien code de cookies de l’ère Netscape imposait une règle littérale de comptage de points. Les navigateurs modernes l’ont remplacée par la vérification du Public Suffix. L’exigence d’un domaine à points est une conséquence de la règle PSL, pas la règle elle-même.


3. Le correctif : /etc/hosts + un domaine à points

Choisissez un domaine à points dont le parent est enregistrable. localhost.com marche bien.

localhost.com est un vrai domaine enregistré appartenant à quelqu’un d’autre, mais ça n’a aucune importance ici, parce que /etc/hosts court-circuite le DNS et le force à 127.0.0.1 sur votre machine. Si vous préférez éviter tout domaine réel, utilisez un TLD de test réservé comme *.myapp.test (.test est réservé par la RFC 6761 et ne sera jamais un vrai site). La mécanique est identique.

Éditer /etc/hosts

/etc/hosts n’a aucun support des wildcards, alors listez chaque sous-domaine explicitement :

# /etc/hosts  (Linux/macOS) — C:\Windows\System32\drivers\etc\hosts sous Windows
127.0.0.1   localhost.com
127.0.0.1   app.localhost.com
127.0.0.1   api.localhost.com
127.0.0.1   auth.localhost.com

Vider le cache DNS

# macOS
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
# Linux (systemd-resolved)
sudo resolvectl flush-caches
# Windows (PowerShell en admin)
ipconfig /flushdns

Vérifier

ping app.localhost.com        # devrait répondre depuis 127.0.0.1
curl -I http://app.localhost.com:3000

Vous voulez une vraie résolution wildcard (pour que n’importe quel *.localhost.com résolve sans éditer le fichier à chaque fois) ? C’est l’évolution dnsmasq, couverte dans l’annexe en bas. Pour la plupart des projets, quatre lignes explicites sont plus simples et suffisent.


4. Maintenant les cookies marchent tout seuls

Avec la résolution en place, posez un cookie de domaine délimité au parent. C’est la seule ligne “consciente des cookies” de tout le setup, et c’est la même ligne que vous utiliseriez en prod.

Serveur (exemple Express)

res.cookie("session", token, {
  domain: ".localhost.com", // ← portée parente ; en prod c'est ".example.com"
  httpOnly: true,
  sameSite: "lax",
  secure: true,             // nécessite HTTPS — voir section 5
  path: "/",
});

L’en-tête brut que le navigateur reçoit :

Set-Cookie: session=...; Domain=.localhost.com; Path=/; HttpOnly; SameSite=Lax; Secure

Résultat

Une requête vers api.localhost.com porte maintenant automatiquement le cookie posé par app.localhost.com, exactement comme entre api.example.com / app.example.com en prod. Le navigateur fait la correspondance ; votre appli ne fait rien de spécial.

Vers la parité : la valeur Domain doit venir de la config, pas d’une condition

Le pattern propre, c’est une seule valeur pilotée par l’environnement, de forme identique partout :

// config
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN; // ".localhost.com" en local, ".example.com" en prod

Aucune branche if (isLocal) nulle part. C’est ça le gain : la parité avec la prod sans aucun cas particulier.


5. Aller plus loin : du vrai HTTPS en local avec nginx

Remarquez le flag Secure plus haut. Les cookies Secure ne sont envoyés que via HTTPS, et SameSite=None (nécessaire pour de vrais flux cross-site) exige Secure. Pour tester le comportement des cookies en toute fidélité, votre env local devrait parler HTTPS aussi. nginx + un certificat wildcard vous y amènent, et maintenant https://app.localhost.com est vraiment proche de la prod.

Générer un certificat wildcard

La voie indolore, c’est mkcert, qui installe une autorité de certification locale de confiance pour que le navigateur n’affiche aucun avertissement :

mkcert -install
mkcert "*.localhost.com" localhost.com
# → produit _wildcard.localhost.com.pem  et  _wildcard.localhost.com-key.pem
Alternative OpenSSL (aucun outil en plus, mais vous devez approuver le certificat à la main)
openssl req -x509 -nodes -newkey rsa:2048 \
  -keyout localhost.com-key.pem \
  -out localhost.com.pem \
  -days 825 \
  -subj "/CN=*.localhost.com" \
  -addext "subjectAltName=DNS:*.localhost.com,DNS:localhost.com"

Puis ajoutez le certificat obtenu au magasin de confiance de votre OS/navigateur.

nginx en reverse proxy terminateur de TLS

nginx écoute sur le 443, termine le TLS, et redirige chaque sous-domaine vers le bon port local :

# /etc/nginx/conf.d/localhost.com.conf
upstream frontend { server 127.0.0.1:3000; }
upstream backend  { server 127.0.0.1:4000; }

server {
    listen 443 ssl;
    server_name app.localhost.com;

    ssl_certificate     /path/to/_wildcard.localhost.com.pem;
    ssl_certificate_key /path/to/_wildcard.localhost.com-key.pem;

    location / {
        proxy_pass http://frontend;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP         $remote_addr;
    }
}

server {
    listen 443 ssl;
    server_name api.localhost.com;

    ssl_certificate     /path/to/_wildcard.localhost.com.pem;
    ssl_certificate_key /path/to/_wildcard.localhost.com-key.pem;

    location / {
        proxy_pass http://backend;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP         $remote_addr;
    }
}
sudo nginx -t && sudo nginx -s reload

Maintenant https://app.localhost.com et https://api.localhost.com sont en ligne, de confiance, partageant un cookie Secure sur .localhost.com. C’est la parité préprod sur votre portable.

Important pour Express derrière un proxy : mettez app.set("trust proxy", 1) pour que les cookies Secure et req.protocol respectent l’en-tête X-Forwarded-Proto envoyé par nginx.


6. La vue d’ensemble

ProductionLocal (ce setup)
Hôte frontendapp.example.comapp.localhost.com
Hôte APIapi.example.comapi.localhost.com
Portée du cookie.example.com.localhost.com
TransportHTTPSHTTPS (nginx + mkcert)
Code applicatifidentique, aucune branche

Les seules choses qui diffèrent entre les environnements sont des valeurs en config (COOKIE_DOMAIN, URL de base), jamais la logique.


7. Pièges et dépannage

  • Le cookie n’est pas posé ? Vérifiez le Domain à point initial, confirmez que vous êtes en HTTPS si Secure est posé, et confirmez que l’hôte de la requête correspond bien au domaine du cookie.
  • CORS avec credentials : un XHR/fetch entre sous-domaines a besoin de withCredentials: true (client), Access-Control-Allow-Credentials: true (serveur), et d’un Access-Control-Allow-Origin spécifique : le wildcard * est interdit quand des credentials sont en jeu.
  • SameSite : Lax couvre la plupart des flux entre sous-domaines du même site. N’utilisez None que pour de vrais contextes cross-site, et rappelez-vous qu’il impose Secure (d’où la section 5).
  • Cache DNS : si un nouveau sous-domaine refuse de résoudre, videz le cache (section 3) ou redémarrez le navigateur.
  • Express derrière nginx : sans trust proxy, les cookies Secure ne seront silencieusement pas envoyés.
  • Certificats périmés : relancez mkcert -install si le navigateur ne fait plus confiance à l’autorité après une mise à jour d’OS.

Aparté bonus : le faire marcher sur mobile

Les applis natives (et certaines webviews) ne persistent pas et ne rejouent pas les cookies comme le fait un navigateur, donc l’élégante histoire navigateur ci-dessus s’effondre sur mobile. Un contournement compact qui garde le comportement serveur identique :

  1. Parsez Set-Cookie avec la même bibliothèque qu’Express, le paquet npm cookie, pour que le parsing soit identique octet pour octet aux attentes du backend.
  2. Persistez tout (par exemple stockage sécurisé / AsyncStorage).
  3. Réinjectez à chaque requête sortante via un intercepteur de requête axios, axios.interceptors.request.use().
import cookie from "cookie";
import axios from "axios";

// Capture : parser et stocker Set-Cookie depuis les réponses
axios.interceptors.response.use((res) => {
  const setCookie = res.headers["set-cookie"];
  if (setCookie) {
    const jar = setCookie.map((c) => cookie.parse(c));
    storage.saveCookies(jar); // votre couche de persistance
  }
  return res;
});

// Rejeu : injecter les cookies stockés à chaque requête
axios.interceptors.request.use((config) => {
  const stored = storage.loadCookies(); // { session: "...", ... }
  config.headers.Cookie = Object.entries(stored)
    .map(([k, v]) => cookie.serialize(k, v))
    .join("; ");
  return config;
});

Utiliser le même parseur que le serveur évite les bugs subtils du genre “ça s’est parsé différemment sur mobile”. Gardez cette couche fine : c’est un pont pour les environnements sans vrai pot à cookies, pas un remplaçant.


Conclusion

Un comportement de cookies digne de la production en local tient en trois gestes : des sous-domaines à points dans /etc/hosts, un Domain délimité au parent, et nginx pour du vrai HTTPS. Le bénéfice, c’est un environnement local qui arrête de vous mentir : même forme d’hôtes, même portée de cookie, même transport, même code. La catégorie des bugs d’authentification qui “ne cassent qu’en staging” disparaît largement.


Annexe : résolution wildcard avec dnsmasq

Si vous ajoutez souvent des sous-domaines et ne voulez pas continuer à éditer /etc/hosts, dnsmasq résout tout un wildcard vers le loopback :

# dnsmasq.conf
address=/localhost.com/127.0.0.1

Combiné au certificat wildcard de la section 5, chaque *.localhost.com que vous inventez marche tout seul : aucune édition par sous-domaine, aucun nouveau certificat. La mise en place diffère selon l’OS (config du résolveur sur macOS via /etc/resolver/, NetworkManager/systemd-resolved sur Linux), alors traitez ça comme le “niveau supérieur” une fois que la version à entrées explicites commence à payer.