Web Application Security: The Developer Role and the Power of HSTS
How HSTS prevents downgrade attacks and protects session cookies.
Security is also code
It is common to treat security as the infrastructure team's job: someone installs the TLS certificate, configures the firewall, and the rest is "already protected". In practice, many of a web application's defenses live in decisions the developer makes every day: how the session is created, which headers the response carries, which attributes the cookie gets, where a redirect points.
HSTS (HTTP Strict Transport Security) is a good example. It is a single HTTP header, defined in RFC 6797, that closes a gap the TLS certificate alone does not close. It costs one line of configuration, but it requires understanding what it does, because a wrong configuration can make parts of your domain unreachable for months.
This article explains the problem HSTS solves, how the browser interprets the header, how to deploy it safely on the most common servers and frameworks, how to verify the result, and what the protection's limits are.
The problem: having HTTPS is not enough
Imagine a site that has a valid certificate and redirects every HTTP request to HTTPS:
GET / HTTP/1.1
Host: example.com
HTTP/1.1 301 Moved Permanently
Location: https://example.com/It looks safe, but look at the order of events. When the user types example.com in the address bar, clicks an old http:// link, or uses a bookmark saved years ago, the first request goes out in plain text. Only afterwards does the server respond, telling the browser to go to HTTPS.
That first request is the attack window. Someone on the same network (public Wi-Fi, a compromised router, a malicious proxy) can:
- Intercept and read the request, including cookies that lack the
Secureattribute. - Answer in place of the server, never passing the redirect on to HTTPS.
SSL stripping
The second item has a name: SSL stripping, presented by Moxie Marlinspike at Black Hat DC in 2009 together with the tool sslstrip. The attacker sits in the middle and:
- talks to the real server over HTTPS;
- talks to the victim over HTTP;
- rewrites every
https://link on the pages tohttp://, keeping the victim on the insecure channel the whole time.
Victim <-- HTTP (plain text) --> Attacker <-- HTTPS --> example.comFor the victim, the site loads normally. Only the padlock is missing, and almost nobody notices. Passwords, tokens and session cookies pass in clear text through the attacker. The server's 301 redirect does not help, because the attacker simply never delivers it.
The underlying problem is that the browser has no way to know, before connecting, that the site should only be accessed over HTTPS. HSTS is exactly that information.
How HSTS works
The server sends, in an HTTPS response, the header:
Strict-Transport-Security: max-age=63072000; includeSubDomainsWhen the browser receives this header over a secure connection without certificate errors, it records the host as an HSTS host for the period given in max-age. From then on, while the policy is valid, the browser changes behavior in two ways.
1. Automatic upgrade from HTTP to HTTPS
Any http:// URL for that host is rewritten to https:// inside the browser, before any packet leaves for the network. This applies to what the user types, links, bookmarks, forms, and subresources (images, scripts, fetch calls) that point to the host.
Per the RFC, if the URL uses port 80 explicitly, it is changed to port 443. An explicit port other than 80 is preserved (http://example.com:8080 becomes https://example.com:8080).
In Chrome, you can see this in DevTools, Network tab: the http:// request appears with status 307 Internal Redirect and the header Non-Authoritative-Reason: HSTS. That 307 did not come from the server; the browser generated it.
2. Certificate errors become a hard block
Normally, when facing an invalid certificate, the browser shows a warning and lets the user "continue anyway". For an HSTS host, RFC 6797 (section 12.1) requires that this option not exist: the error is fatal and the page does not open.
This matters because an attacker who cannot perform SSL stripping may try to present a forged certificate and count on the user clicking "proceed". With HSTS, that click does not exist.
Processing rules you need to know
These details come from RFC 6797 and explain much of the deployment trouble:
- The header is only valid over HTTP. If it arrives in a plain HTTP response, the browser ignores it (section 8.1). This makes sense: an attacker could inject or remove the header in a plain-text response. Therefore, putting the header in the server block that serves port 80 has no effect at all.
- The certificate must be valid. If there is any TLS error on the connection, the header is ignored.
- Hosts that are IP addresses get no policy.
https://192.0.2.10may send the header, but the browser does not record it (section 8.1.1). - Only the first header counts. If the response has two
Strict-Transport-Securityheaders, only the first is processed. This often happens when the application and the reverse proxy both add the header with different values. <meta http-equiv>does not work. The browser must ignore HSTS declared through a<meta>tag in the HTML (section 8.5). It has to be an HTTP header.- Each response renews the policy. The
max-agecountdown is recalculated from the moment the last valid response was received. A user who visits the site every week never lets the policy expire.
The directives
max-age (required)
Time, in seconds, during which the browser should remember the policy. Reference values:
| Value | Duration | Typical use |
|---|---|---|
300 | 5 minutes | First test in production |
604800 | 1 week | Intermediate step |
2592000 | 30 days | Intermediate step |
31536000 | 1 year | Minimum required for preload |
63072000 | 2 years | Final value recommended by hstspreload.org |
max-age=0 has a special meaning: it instructs the browser to delete the policy for that host. It is the rollback mechanism (with caveats, see below).
includeSubDomains (optional)
Extends the policy to all subdomains of the host that sent the header. If example.com sends includeSubDomains, then api.example.com, admin.example.com and anything.at.example.com also become HTTPS-only.
This directive is the most protective and also the one that breaks the most things. Before enabling it, inventory every subdomain: internal dashboards, staging environments, legacy services, third-party tools pointed via CNAME. Any that only respond on HTTP, or have an invalid certificate, will become unreachable for anyone who already received the policy.
preload (optional, outside the RFC)
It is not part of RFC 6797. It is a convention used by the preload list (explained below) to indicate that the domain owner consents to inclusion. On its own, it does not change browser behavior.
The syntax accepts the directives in any order, case-insensitive, separated by ;.
HSTS and session cookies
The session cookie is, in many applications, the most valuable credential traveling on the network: whoever has the cookie has the account. HSTS strengthens this protection in two places that the Secure attribute alone does not cover well.
1. No HTTP request gets sent at all. A cookie without Secure is sent with any request to the domain, including over HTTP. Without HSTS, the first plain-text request (before the redirect) already carries the cookie in clear. With HSTS, the browser upgrades internally, and the cookie only travels inside TLS.
2. Nonexistent subdomains stop being an opening. Consider a cookie set with Domain=example.com. It is sent to any subdomain. An attacker on the network can inject, into any HTTP page the victim visits, something like:
<img src="http://does-not-exist.example.com/x.png">The browser tries to load the image and, if the cookie is not Secure, attaches it to the request in plain text, which the attacker reads. It does not matter that the subdomain does not exist: the attacker controls the network and answers for it. With includeSubDomains on example.com, that request is promoted to HTTPS, and the attacker cannot present a valid certificate for it.
The same applies in the reverse direction, cookie injection. Forged HTTP responses for a subdomain can set cookies with Domain=example.com that overwrite or shadow yours (a technique known as cookie tossing), opening room for session fixation. includeSubDomains closes this channel at the transport layer.
HSTS does not replace cookie attributes
HSTS and cookie attributes are complementary layers. A well-configured session cookie looks like this:
Set-Cookie: __Host-session=9f8b...; Path=/; Secure; HttpOnly; SameSite=LaxSecure: the cookie is never sent outside HTTPS, even if some host escapes the HSTS policy (for example, on the first visit).HttpOnly: JavaScript cannot read the cookie, which reduces the impact of XSS.SameSite=Lax(orStrict): limits sending on requests coming from other sites, mitigating CSRF.__Host-prefix: the browser only accepts the cookie if it hasSecure,Path=/and noDomainattribute. This binds the cookie to the exact host and prevents a subdomain from overwriting it.
The first-visit problem and the preload list
HSTS has a structural limitation: the browser only knows the policy after receiving the header at least once. The first visit from each browser, or the first visit after the policy expired, remains vulnerable. This model is called TOFU (trust on first use).
The solution is the preload list: a list of domains embedded in the Chromium source code, which ships to users with the browser. Chrome, Edge, Firefox and Safari use lists derived from it. A domain on the list is treated as an HSTS host from installation, without any prior visit.
Some entire TLDs are on the list, such as .dev and .app. That is why a .dev domain never opens over plain HTTP in those browsers, even without any header.
Requirements to join the list
Submission is done at hstspreload.org, and only registrable domains (such as example.com, not www.example.com) can be submitted. The published requirements are:
- Have a valid certificate.
- Redirect HTTP to HTTPS on the same host, if the server listens on port 80. That is,
http://example.commust redirect tohttps://example.com, not directly tohttps://www.example.com. - Serve all subdomains over HTTPS. In particular,
wwwmust work over HTTPS if a DNS record exists for it. - Send the HSTS header on the base domain, in HTTPS responses, with:
max-ageof at least31536000(1 year);includeSubDomains;preload;- if the HTTPS response is a redirect, the redirect itself must contain the header (the destination page alone is not enough).
Think before submitting
Inclusion is quick to request and slow to undo. Removal is done through the same site, but only reaches users when they update their browser, which can take months. During that time, all subdomains of the domain remain required to use valid HTTPS. Submit only when you are sure no subdomain, present or future, will need HTTP.
Step-by-step deployment
The order below minimizes the risk of leaving users stuck with a wrong policy.
Step 1: HTTPS working everywhere. Valid certificate on the domain and on every subdomain you intend to cover, complete chain, and automatic renewal (an expired certificate on an HSTS host means a site down with no "continue" button).
Step 2: HTTP to HTTPS redirect. Every request on port 80 receives a 301 or 308 to the same URL over HTTPS, on the same host.
Step 3: header with a short max-age. Start with max-age=300, without includeSubDomains. If something goes wrong, the policy disappears in 5 minutes.
Step 4: increase gradually. Monitor and raise it to one week, then one month. Add includeSubDomains only after the subdomain inventory.
Step 5: final value. max-age=63072000; includeSubDomains. If you want preload, add preload and submit at hstspreload.org.
Below are configurations for the most common servers and frameworks. The examples use the final value; adjust max-age according to the step you are on.
Nginx
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# No HSTS here: over HTTP the header would be ignored.
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# ...
}Two Nginx details cause most of the problems:
always: without it,add_headeris only applied to responses with status 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308. Withalways, the header is also sent on error responses.add_headerinheritance: alocationblock that defines anyadd_headerstops inheriting alladd_headerdirectives from theserverlevel. If you add, for example, aCache-Controlinlocation /static/, HSTS disappears from those responses. Repeat the header in thelocationor centralize headers in a file included withinclude.
In Nginx versions before 1.25.1, the http2 on; directive does not exist; use listen 443 ssl http2;.
Apache (httpd)
Requires the mod_headers module (a2enmod headers on Debian/Ubuntu).
<VirtualHost *:80>
ServerName example.com
ServerAlias www.example.com
Redirect permanent / https://example.com/
</VirtualHost>
<VirtualHost *:443>
ServerName example.com
ServerAlias www.example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
</VirtualHost>Header always set guarantees the header even on error responses and replaces any previous value, avoiding duplication. Note: this example redirects www over HTTP straight to example.com. To meet requirement 2 of preload on www.example.com, split www into its own VirtualHost that redirects to https://www.example.com/.
Caddy
Caddy obtains certificates and redirects HTTP to HTTPS automatically, but does not send HSTS by default:
example.com {
header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
reverse_proxy localhost:3000
}Next.js
In next.config.ts, the headers() function applies the header to all routes:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
],
},
];
},
};
export default nextConfig;If Next.js runs behind a proxy or a platform that already sends HSTS, check the final response so you do not end up with two different headers.
Node.js with Express
The Helmet package handles the header. Note that maxAge is in seconds:
import express from "express";
import helmet from "helmet";
const app = express();
app.use(
helmet({
strictTransportSecurity: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
}),
);Helmet sends HSTS by default even without this explicit configuration; the block above only adjusts the values.
ASP.NET Core
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(730);
options.IncludeSubDomains = true;
options.Preload = true;
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();Notes: the middleware's default MaxAge is only 30 days; UseHsts does not send the header for localhost, 127.0.0.1 and [::1]; and the header is only emitted when the request arrives as HTTPS.
Spring Boot (Spring Security 6)
Spring Security already sends max-age=31536000 ; includeSubDomains by default, only on HTTPS requests. To adjust:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.preload(true)
.maxAgeInSeconds(63072000)
)
);
return http.build();
}Warning: TLS terminated at the proxy
In many architectures, TLS terminates at a load balancer, CDN or reverse proxy, and the application receives the request as plain HTTP. Frameworks that only emit HSTS on "secure" requests (ASP.NET Core, Spring Security) will conclude the request is not HTTPS and silently omit the header.
There are two fixes:
- add the header at the edge (proxy, load balancer or CDN), which is what actually speaks HTTPS with the browser; or
- configure the application to trust
X-Forwarded-Protofrom the proxy:UseForwardedHeadersin ASP.NET Core,server.forward-headers-strategy=native(orframework) in Spring Boot,app.set("trust proxy", ...)in Express.
Choose one point to emit the header. Remember that with two headers on the same response, the browser uses only the first.
How to verify
Header in the HTTPS response:
curl -sI https://example.com | grep -i strict-transport-securityExpected output:
strict-transport-security: max-age=63072000; includeSubDomains; preloadAlso check that there is exactly one line.
Redirect on port 80 to the same host:
curl -sI http://example.comExpect 301 (or 308) with Location: https://example.com/.
State in the browser: in Chrome or Edge, open chrome://net-internals/#hsts (or edge://net-internals/#hsts). Under Query HSTS/PKP domain, look up the domain: you will see whether there is a dynamic policy (learned via header) or a static one (preload), the expiration date, and whether it covers subdomains. On the same page, Delete domain security policies removes a dynamic policy, useful in testing. Preload policies cannot be deleted there.
Preload status: on hstspreload.org, enter the domain to see whether it meets the requirements or is already on the list.
Rollback and common pitfalls
How to turn it off. Send Strict-Transport-Security: max-age=0 over HTTPS. Each browser that receives that response deletes the policy. The problem is users who never return to the site: they keep the policy until the original max-age expires. That is why gradually increasing max-age matters. For preloaded domains, you must also request removal from the list and wait for new browser versions.
HSTS on localhost. If a development environment sends the header, the browser will start forcing HTTPS on localhost for all your local projects. Disable HSTS in development and, if it already happened, delete the policy at chrome://net-internals/#hsts.
includeSubDomains on the wrong domain. If the header with includeSubDomains is sent by example.com, it affects intranet.example.com even if no one on the main site team knows that host exists. This is the most common HSTS incident.
Expired certificate. On an HSTS host, an expired certificate produces no bypassable warning: the site simply does not open. Monitor validity and automate renewal.
Header only on some responses. Error pages, HTTPS redirects, and routes with their own configuration sometimes go out without the header. For preload, the HTTPS redirect must carry it.
What HSTS does not solve
HSTS is one layer, not a complete solution. It does not protect against:
- The first visit to domains outside the preload list. The TOFU model still applies.
- Improperly issued certificates. If a certificate authority issues a valid certificate for your domain to an attacker, HSTS accepts it. The mitigations are the Certificate Transparency ecosystem and the CAA record in DNS, which limits which CAs may issue for you.
- Clock manipulation. An attacker who can change the system time (for example, via unauthenticated NTP) can make the policy appear expired. This attack was publicly demonstrated in 2014.
- Non-browser clients.
curl, HTTP libraries and many apps ignore HSTS by default. (curlhas optional support since version 7.74.0, via--hsts <file>.) In system-to-system integrations, always usehttps://URLs and validate certificates. - Application vulnerabilities. XSS, SQL injection, CSRF and authorization flaws remain the code's responsibility.
Checklist
- Valid HTTPS on the domain and on every subdomain covered, with automatic renewal.
- Port 80 redirects with
301/308to HTTPS on the same host. -
Strict-Transport-Securitysent only on HTTPS responses, including errors and redirects. - Exactly one header per response.
-
max-ageraised gradually to63072000. - Subdomain inventory done before
includeSubDomains. - Session cookies with
Secure,HttpOnly,SameSiteand, if possible, the__Host-prefix. - HSTS disabled in development.
- Preload submitted only after everything above is stable.
Conclusion
HSTS turns an intent ("this site only works over HTTPS") into a rule the browser enforces on its own, before any packet leaves for the network. It eliminates SSL stripping, prevents cookies from leaking over HTTP requests, and removes the option for the user to ignore a suspicious certificate.
It is the kind of protection that depends on the developer: someone must know the header exists, configure it in the right place in the architecture, test the result, and plan the rollout so subdomains are not broken. One line of configuration, as long as you understand what it does.