felipe@dumont:~/blog/web-security-hsts$
cd ../blog
#segurança#web

Web Application Security: The Developer Role and the Power of HSTS

How HSTS prevents downgrade attacks and protects session cookies.

Felipe Dumont·November 6, 2026·available in PT / EN

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:

http
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:

  1. Intercept and read the request, including cookies that lack the Secure attribute.
  2. 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 to http://, keeping the victim on the insecure channel the whole time.
text
Victim  <-- HTTP (plain text) -->  Attacker  <-- HTTPS -->  example.com

For 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:

http
Strict-Transport-Security: max-age=63072000; includeSubDomains

When 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.10 may 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-Security headers, 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-age countdown 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:

ValueDurationTypical use
3005 minutesFirst test in production
6048001 weekIntermediate step
259200030 daysIntermediate step
315360001 yearMinimum required for preload
630720002 yearsFinal 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:

html
<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:

http
Set-Cookie: __Host-session=9f8b...; Path=/; Secure; HttpOnly; SameSite=Lax
  • Secure: 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 (or Strict): limits sending on requests coming from other sites, mitigating CSRF.
  • __Host- prefix: the browser only accepts the cookie if it has Secure, Path=/ and no Domain attribute. 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:

  1. Have a valid certificate.
  2. Redirect HTTP to HTTPS on the same host, if the server listens on port 80. That is, http://example.com must redirect to https://example.com, not directly to https://www.example.com.
  3. Serve all subdomains over HTTPS. In particular, www must work over HTTPS if a DNS record exists for it.
  4. Send the HSTS header on the base domain, in HTTPS responses, with:
    • max-age of at least 31536000 (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

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_header is only applied to responses with status 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308. With always, the header is also sent on error responses.
  • add_header inheritance: a location block that defines any add_header stops inheriting all add_header directives from the server level. If you add, for example, a Cache-Control in location /static/, HSTS disappears from those responses. Repeat the header in the location or centralize headers in a file included with include.

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).

apache
<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:

text
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:

ts
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:

js
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

csharp
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:

java
@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-Proto from the proxy: UseForwardedHeaders in ASP.NET Core, server.forward-headers-strategy=native (or framework) 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:

bash
curl -sI https://example.com | grep -i strict-transport-security

Expected output:

text
strict-transport-security: max-age=63072000; includeSubDomains; preload

Also check that there is exactly one line.

Redirect on port 80 to the same host:

bash
curl -sI http://example.com

Expect 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. (curl has optional support since version 7.74.0, via --hsts <file>.) In system-to-system integrations, always use https:// 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/308 to HTTPS on the same host.
  • Strict-Transport-Security sent only on HTTPS responses, including errors and redirects.
  • Exactly one header per response.
  • max-age raised gradually to 63072000.
  • Subdomain inventory done before includeSubDomains.
  • Session cookies with Secure, HttpOnly, SameSite and, 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.

References

Liked it? Share it or talk to me.