HSTS : quel max-age poser, et pourquoi tu ne dois pas activer preload tout de suite
La bonne valeur de départ pour HSTS n'est pas max-age=31536000. C'est max-age=300. L'en-tête Strict-Transport-Security demande au navigateur de ne plus jamais parler à cet hôte en HTTP, et le max-age lui dit combien de secondes il doit obéir.
Sommaire
- Ce que le navigateur retient d'un en-tête HSTS
- Le max-age est une durée d'engagement, pas un réglage
- Le cas du certificat qui expire
- Le déploiement en trois paliers
- includeSubDomains demande un inventaire, pas un avis
- Vérifier ce qui part réellement
- Pourquoi preload est la dernière étape, pas la première
- Ce que HSTS ne fait pas
- La ligne à retenir
Un an de HSTS est un engagement que rien ne permet d'annuler depuis ton serveur. La politique ne vit pas dans ton fichier Nginx : elle vit dans le navigateur de chaque visiteur, pour chaque hôte, jusqu'à l'expiration du compteur. Si le certificat casse pendant ce délai, le navigateur refuse la connexion et ne propose même pas de bouton « continuer malgré tout » : sur un hôte HSTS, l'avertissement TLS devient bloquant. Aucun déploiement ne rattrape ça, parce que ta config n'est plus dans la boucle.
La séquence qui ne casse rien : max-age=300, puis 604800 (7 jours), puis 2592000 (30 jours), en attendant à chaque palier la durée complète avant de passer au suivant. C'est la recommandation de hstspreload.org, le site qui gère la liste embarquée dans Chrome.
Ce qu'il ferme concrètement : un attaquant sur le chemin ne peut plus réécrire ta redirection HTTP → HTTPS pour garder le visiteur en clair, ni injecter un cookie dans cette redirection.
Ce que le navigateur retient d'un en-tête HSTS
Il n'est lu que sur HTTPS. Une réponse en clair contenant Strict-Transport-Security est ignorée, volontairement : sinon un attaquant en position de relais pourrait allonger ou raccourcir la politique. Donc ta redirection 301 HTTP → HTTPS doit fonctionner avant que HSTS ne serve à quoi que ce soit.
La portée est l'hôte, pas le domaine. MDN le précise : HSTS identifie un hôte par son nom de domaine, jamais par une IP, et couvre tous ses ports. example.com et www.example.com sont deux entrées distinctes, et chaque sous-hôte doit envoyer son propre en-tête, sinon rien ne le protège.
Le compteur se renouvelle à chaque réponse. Le navigateur recalcule l'expiration à partir de maintenant chaque fois qu'il reçoit l'en-tête : un visiteur qui revient toutes les semaines ne perd jamais le pin. À l'inverse, supprimer l'en-tête de ta config n'efface rien — l'ancienne politique reste active jusqu'à sa date d'expiration.
Le max-age est une durée d'engagement, pas un réglage
Le cas du certificat qui expire
Une équipe déploie max-age=31536000; includeSubDomains un vendredi, tout fonctionne. Six mois plus tard, le renouvellement ACME échoue pour une raison banale : port 80 fermé par un changement de firewall, challenge DNS mal repointé, cron mort. Le certificat expire. Le site devient inaccessible — pas « avec un avertissement », inaccessible — pour tous les visiteurs déjà passés, et cette fois le déploiement d'un certificat valide ne suffit plus. Tu diagnostiques un incident en aveugle sur des utilisateurs que tu ne peux pas débloquer.
Le max-age long n'est pas interdit : il est la conséquence d'un système d'émission de certificats que tu sais automatique et surveillé. Tant que ce n'est pas le cas, il transforme une erreur de renouvellement en panne totale.
Le déploiement en trois paliers
Nginx, avec la directive always :
server {
listen 443 ssl;
server_name example.com;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=300; includeSubDomains" always;
}
always n'est pas décoratif. Sans lui, add_header de nginx ne s'applique qu'à une liste de codes (200, 201, 204, 206, 301, 302, 303, 304, 307, 308) : une 404 ou une 500 part sans l'en-tête. Ça n'annule pas la politique déjà enregistrée, mais ça prive tes pages d'erreur du seul mécanisme qui empêche le navigateur de retomber en HTTP.
Caddy, qui gère certificat et redirection :
example.com {
header Strict-Transport-Security "max-age=300; includeSubDomains"
}
Next.js, via next.config.js (valable aussi derrière Vercel) :
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'Strict-Transport-Security', value: 'max-age=300; includeSubDomains' },
],
},
]
},
}
Monter à 604800 le lendemain saute l'étape de test : les 5 minutes ne valident qu'une chose, qu'aucun sous-domaine ne casse dans la foulée.
includeSubDomains demande un inventaire, pas un avis
includeSubDomains étend la politique à tous les sous-domaines et sous-sous-domaines. hstspreload.org insiste sur un point que la plupart des tutoriels oublient : ça inclut les sous-domaines internes non publics. Un admin.interne.example.com résolu seulement sur le VPN, une vieille interface de supervision en HTTP, un CNAME pointé chez un prestataire qui n'a jamais configuré TLS — tous deviennent inaccessibles en HTTP.
L'inventaire se fait avant la ligne de config, pas après. Ce script donne l'état réel :
#!/bin/bash
# hosts.txt : un FQDN par ligne
while read -r h; do
code=$(curl -sIo /dev/null -w '%{http_code}' --max-time 8 "https://$h" 2>/dev/null || echo "KO")
redir=$(curl -sIo /dev/null -w '%{redirect_url}' --max-time 8 "http://$h" 2>/dev/null)
printf '%-34s https=%-4s http->%s\n' "$h" "$code" "$redir"
done < hosts.txt
Un https=000 (connexion refusée ou certificat invalide) est exactement le cas qui deviendra une panne bloquante. Et une redirection HTTP qui pointe ailleurs que sur le même hôte en HTTPS ne satisfait pas les exigences de preload.
Vérifier ce qui part réellement
curl -sI https://example.com | grep -i strict-transport-security
# strict-transport-security: max-age=300; includeSubDomains
Si rien ne sort, la ligne est soit dans un server block qui ne match pas, soit écrasée par un add_header plus spécifique : dans nginx, un add_header de niveau inférieur annule l'héritage.
Pourquoi preload est la dernière étape, pas la première
La liste de preload existe pour un seul cas : la première visite. Avant d'avoir reçu l'en-tête, le navigateur ignore ta politique ; preload l'embarque dans son binaire.
Et hstspreload.org le dit noir sur blanc : « The benefits provided by HSTS preloading are minimal compared to the benefits provided by HSTS. While HSTS is recommended, HSTS preloading is not recommended. » L'argument du site : la plupart des navigateurs mettent déjà à niveau les navigations HTTP vers HTTPS indépendamment de HSTS, donc preload ne protège que contre un attaquant actif au premier chargement.
Pour être accepté, il faut un certificat valide, une redirection HTTP → HTTPS sur le même hôte, tous les sous-domaines en HTTPS, et un en-tête avec max-age d'au moins 31536000, includeSubDomains et preload. Pour en sortir, hstspreload.org prévient : les changements mettent des mois à atteindre les utilisateurs via une mise à jour de Chrome, sans garantie sur les autres navigateurs. L'ajout est codé en dur dans les sources de Chrome et prend lui aussi plusieurs mois avant d'atteindre la version stable.
Un template qui active preload par défaut fait donc prendre une décision irréversible à ses utilisateurs. Le site de la liste demande explicitement aux mainteneurs de ne pas le faire.
L'ordre correct : HSTS sans preload, paliers jusqu'à max-age=31536000, inventaire des sous-domaines tenu à jour, renouvellement de certificat automatique et supervisé — et seulement ensuite, si tu veux couvrir le premier chargement, la soumission.
Ce que HSTS ne fait pas
HSTS règle le transport entre le navigateur et ton hôte. Il ne touche à rien d'autre.
Il ne chiffre rien. Le chiffrement vient de TLS ; un ssl_protocols qui accepte encore TLS 1.0 ou 1.1 reste un problème après l'ajout de HSTS.
Il ne pose pas le flag Secure sur tes cookies. Un cookie sans Secure continuera de partir en clair pour tout client qui parle encore HTTP à ton domaine — scripts, webhooks, vieux clients. Le guide cookie httpOnly détaille les attributs à poser, dont __Host- qui impose Secure côté navigateur.
Il ne protège pas le contenu : le transport peut être impeccable et le script injecté toujours exécuté, c'est le rôle de Content Security Policy, une brique séparée qui ne se déduit pas de HSTS.
Il ne corrige aucune logique applicative. Une API qui renvoie les données d'un autre utilisateur le fait aussi bien en HTTPS ; le contournement du middleware Next.js CVE-2025-29927 le montre : le trafic était chiffré de bout en bout, l'autorisation ne l'était pas.
HSTS ne règle pas non plus les cookies au sens légal — durée de vie, finalité, consentement : c'est un sujet distinct, traité dans cookies et CNIL.
La ligne à retenir
add_header Strict-Transport-Security "max-age=300; includeSubDomains" always; puis on monte, et on ne touche pas à preload avant d'avoir prouvé que chaque sous-domaine, y compris interne, sert du HTTPS valide depuis des mois. Le max-age que tu écris aujourd'hui s'applique à des gens dont tu ne contrôleras plus le navigateur demain.
Rustine vérifie l'en-tête sur le domaine et les sous-domaines qu'il découvre, avec le reste des headers et la version TLS, dans le même scan.