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.
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 frontendapi.example.com, le backendauth.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, frontendlocalhost: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 surDomain). - Le comportement de
SameSite/Securene 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:3000est envoyé àlocalhost:4000, même hôte. Ça fait justement partie du problème :localhostnu 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ôte | TLD effectif | Domaine enregistrable (eTLD+1) | Pouvez-vous poser Domain=<parent> ? |
|---|---|---|---|
app.localhost | localhost | app.localhost | ❌ Domain=localhost est un suffixe public → rejeté |
app.localhost.com | com | localhost.com | ✅ Domain=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.localhostpour 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.comest un vrai domaine enregistré appartenant à quelqu’un d’autre, mais ça n’a aucune importance ici, parce que/etc/hostscourt-circuite le DNS et le force à127.0.0.1sur votre machine. Si vous préférez éviter tout domaine réel, utilisez un TLD de test réservé comme*.myapp.test(.testest 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.comrésolve sans éditer le fichier à chaque fois) ? C’est l’évolutiondnsmasq, 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 cookiesSecureetreq.protocolrespectent l’en-têteX-Forwarded-Protoenvoyé par nginx.
6. La vue d’ensemble
| Production | Local (ce setup) | |
|---|---|---|
| Hôte frontend | app.example.com | app.localhost.com |
| Hôte API | api.example.com | api.localhost.com |
| Portée du cookie | .example.com | .localhost.com |
| Transport | HTTPS | HTTPS (nginx + mkcert) |
| Code applicatif | identique, 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 siSecureest 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’unAccess-Control-Allow-Originspécifique : le wildcard*est interdit quand des credentials sont en jeu. SameSite:Laxcouvre la plupart des flux entre sous-domaines du même site. N’utilisezNoneque pour de vrais contextes cross-site, et rappelez-vous qu’il imposeSecure(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 cookiesSecurene seront silencieusement pas envoyés. - Certificats périmés : relancez
mkcert -installsi 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 :
- Parsez
Set-Cookieavec la même bibliothèque qu’Express, le paquet npmcookie, pour que le parsing soit identique octet pour octet aux attentes du backend. - Persistez tout (par exemple stockage sécurisé /
AsyncStorage). - 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.