How to Configure max-age and includeSubDomains for HSTS
This guide sets the two directives that control HSTS scope and lifetime. The protocol mechanics, RFC 6797 threat model, and preload trade-offs behind these values live in the HTTP Strict Transport Security (HSTS) deep dive; here the focus is the exact wire values and the safe path to a one-year policy.
Configuration Syntax & Exact Values
Strict-Transport-Security: max-age=63072000; includeSubDomains
max-age=63072000— enforcement lifetime in seconds, counted from each response.63072000is two years, the value Chromium’s preload guidance and most hardening baselines recommend;31536000(one year) is the minimum that qualifies for preload. The browser refreshes this countdown on every response, so an active site effectively never lapses.includeSubDomains— a valueless flag that extends the policy to every host below the issuing one (api.example.com,cdn.example.com, and hosts the browser has never visited). It is the directive that closes downgrade gaps on forgotten subdomains, and the one that causes lockouts when a subdomain lacks matching TLS.
Both directives are case-insensitive and order-independent. Do not quote the value inside the directive (max-age="63072000" is malformed and silently dropped). Add preload only when you have committed to the near-permanent enforcement trade-offs and reached the full two-year value.
Because max-age is expressed in raw seconds, the common milestones are worth memorizing: 300 (five minutes) for a first staging probe, 86400 (one day), 2592000 (thirty days), 31536000 (one year, the preload floor), and 63072000 (two years, the hardening target). A leading zero, a decimal, or thousands separators are all invalid; the field must be a bare non-negative integer. If a client receives a header whose max-age cannot be parsed, it ignores the entire Strict-Transport-Security directive for that response rather than falling back to a default — a silent failure that only surfaces when you inspect the stored entry in chrome://net-internals/#hsts.
Server-Side Configuration
Emit the header once, at the outermost layer that touches every response. Declaring it at both the CDN and the origin produces two copies; pick one source.
Nginx
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
always forces emission on 4xx/5xx responses; without it the header vanishes from error pages. An add_header in a location block replaces inherited headers, so repeat this line in any location that sets its own.
Apache
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
always appends the header even on internally generated error documents, which the default onsuccess table skips. Requires mod_headers (a2enmod headers).
Cloudflare
Dashboard → SSL/TLS → Edge Certificates → HTTP Strict Transport Security (HSTS). Enable it, set Max-Age to 12 months or longer, and toggle Include subdomains to On. Cloudflare emits the header at the edge on all proxied responses; delete any duplicate origin add_header so only one copy reaches the browser.
Node/Helmet
const helmet = require('helmet');
app.use(helmet.hsts({ maxAge: 63072000, includeSubDomains: true }));
Register Helmet before route and error handlers so short-circuited responses still carry the header. Behind a TLS-terminating proxy, set app.set('trust proxy', 1) so Express treats forwarded requests as secure.
How includeSubDomains Expands Scope
Without the flag, an HSTS policy set on example.com protects only that exact host; api.example.com and login.example.com remain independently downgradeable and can still be stripped to plaintext by an on-path attacker. includeSubDomains collapses that gap by binding the policy to the entire subtree. Critically, the browser applies the policy to hosts it has never contacted: the first time a user is lured to victim-lookalike.example.com, the cached apex policy already forces HTTPS, so a forged plaintext redirect never leaves the browser.
That reach is exactly why the flag is dangerous to enable blind. Enforcement is keyed on the registered name, not on which certificate is actually presented, so any subtree host whose TLS is missing, self-signed, or scoped to the wrong Common Name becomes an instant, non-bypassable failure. A wildcard *.example.com certificate covers only one label of depth — a.example.com but not a.b.example.com — so deep hierarchies need either a multi-level wildcard strategy or per-host certificates before the flag is safe.
There is a second, quieter consequence: includeSubDomains interacts with cookie scoping. A Secure cookie set on the apex is now guaranteed to travel only over HTTPS to every subdomain, closing the classic cookie-injection path where an attacker forces a plaintext subdomain request to plant or read a session cookie. That benefit is only real once the flag is stable, which is why the audit below runs against every host in the zone, not just the ones in active use.
Diagnostic & Verification Steps
Confirm the exact wire value:
curl -sI https://yourdomain.com | grep -i strict-transport-security
Expected output:
strict-transport-security: max-age=63072000; includeSubDomains
Verify subdomain coverage before trusting includeSubDomains — the flag enforces on hosts even if their certificate does not cover them:
openssl s_client -connect api.yourdomain.com:443 -servername api.yourdomain.com < /dev/null 2>/dev/null \
| grep -E 'Verify return code|subject='
Expected output: Verify return code: 0 (ok) on every subdomain you intend to cover.
Batch-check every host before enabling includeSubDomains. A single failing subdomain is enough to lock users out, so drive the certificate check across the whole zone rather than spot-checking:
for h in api cdn internal monitoring old www; do
printf '%s: ' "$h.yourdomain.com"
echo | openssl s_client -connect "$h.yourdomain.com:443" \
-servername "$h.yourdomain.com" 2>/dev/null \
| grep -m1 'Verify return code' || echo 'UNREACHABLE'
done
Expected output: Verify return code: 0 (ok) on every line. Any UNREACHABLE, timeout, or non-zero code is a host that will break under includeSubDomains — resolve it before you raise the scope.
Browser DevTools: Network tab → filter Doc → select the document request → Response Headers → confirm the exact value and that no duplicate Strict-Transport-Security line is present. Cross-check the cached entry at chrome://net-internals/#hsts via Query HSTS/PKP domain; the returned JSON echoes static_upgrade_mode, dynamic_sts_include_subdomains, and the stored expiry, which lets you confirm the browser actually recorded the scope and lifetime you sent rather than a stale earlier policy.
Edge Cases, Security Implications & Safe Rollback
A misconfigured subdomain surfaces as NET::ERR_CERT_COMMON_NAME_INVALID with no proceed button — HSTS errors are non-bypassable by design. Three traps dominate:
- Subdomain certificate gaps.
includeSubDomainsenforces HTTPS oninternal.,monitoring., and legacy API hosts whether or not their certificate matches. Audit every host (subfinder+httpx, or your DNS zone export) before enabling the flag. - Cache stickiness on rollback. Reducing
max-agedoes not clear existing browser caches. To revert you must actively serveStrict-Transport-Security: max-age=0; includeSubDomainsover HTTPS, and it only reaches clients that revisit before their existingmax-ageexpires. A botched two-year policy can lock a user out for two years with no server-side fix. - Preload lock-in. Submitting to the preload list is effectively permanent; removal takes months of browser release cycles. Never preload until
includeSubDomainsis proven across staging, legacy, and development hosts.
When a lockout does happen, the only server-side lever is to keep the broken host reachable over valid HTTPS and serve Strict-Transport-Security: max-age=0 from it so revisiting browsers drop the cached entry — but this reaches a client only on its next successful HTTPS handshake, which is precisely what a certificate failure prevents. That circular dependency is why the fix must be a working certificate on the failing host, not a header change: restore TLS first, and the max-age=0 response can then land. There is no way to push a reset to a browser that will not complete the handshake, and affected users cannot click through, so the practical blast radius of a bad two-year policy is every visitor until their local cache expires.
The safe path is therefore to ramp max-age in stages, validating subdomain reachability at each step before the value grows long enough to be costly to undo. Keep the low-value stages long enough to catch real-world traffic — a 300-second policy in staging tells you nothing if no one exercises the subdomains during that window — and only commit the two-year value once a full business cycle has passed without a certificate or reachability incident.
Frequently Asked Questions
What max-age value should I use in production?
63072000 (two years) is the recommended hardening value and exceeds the one-year (31536000) preload floor. Anything shorter than one year disqualifies the domain from preloading.
Will increasing max-age reset the timer for returning visitors? Yes. Browsers recompute expiry from each response, so a visitor who hits a longer value adopts the new lifetime immediately. There is no need to wait for the old value to lapse before raising it.
Can I roll back just by lowering max-age?
No. Lowering the value affects only future responses; cached policies persist until their original max-age expires. Active rollback requires serving max-age=0 over HTTPS and waiting for clients to revisit.
Is includeSubDomains safe to add immediately? Only after auditing TLS on every subdomain. The flag enforces HTTPS on hosts the browser has never seen, and any host without a matching certificate produces a non-bypassable error.
Conclusion
Start at max-age=300 in staging to prove every subdomain — internal tooling, dashboards, APIs — answers over HTTPS, then ramp to 86400, 2592000, and finally 63072000 with includeSubDomains in production. Add preload and submit to hstspreload.org only after the two-year policy has been stable in production, because that step is effectively permanent.