The HSTS header is an underrated security mechanism that makes your daily browsing more secure!

Whilst it certainly secures your daily browsing, there is more to the HSTS header when looked at in detail. Did you for example know the HSTS header can be used as a tracking mechanism? Or that Firefox caps the number of stored HSTS entries at 1024? And have you heard that HSTS can be bypassed with HTTP header injection? Starting with the very basics, this blog post will cover all of the above topics. Let’s dive in! ⤵


HSTS stands for HTTP Strict Transport Security. If the server’s response contains the HSTS header, future requests to the site will always use HTTPS (TLS encrypted). This prevents machine in the middle and especially downgrade attacks like SSL Stripping.

To understand why HSTS is absolutely necessary for secure browsing, let’s take a look at an attack scenario.

As you know, communication via HTTP is insecure. Unfortunately, browsers by default still operate HTTP-first nowadays. This means when you type into the address bar of your browser and hit Enter, at first an HTTP request to will be sent to the server. As the operator of that website is security-savvy, the response contains a redirect to HTTPS. A follow-up request is thus sent to and subsequent requests will be encrypted.

A diagram depicting the above explanation. The friendly user sends an HTTP GET request to the server, which responds with a redirect to HTTPS. The follow-up request is sent using HTTPS. The response contains the content of the webpage transmitted via HTTPS.

A typical HTTP redirect to HTTPS

Nevertheless, this scenario is vulnerable. A machine in the middle attacker can simply intercept the request, send it to the server using HTTPS and relay the response to the victim.

Even if a server only offers HTTPS, the HTTP-first setting of browsers will sent an unencrypted HTTP request first. This request is vulnerable in the same way as above.

A malicious entity now sits between the friendly user and the server. It intercepts the content of the HTTP request of the friendly user and relays it to the server using HTTPS. The server responds with the content of the webpage to the malicious entity, which forwards the content via HTTP to the friendly user.

Attacking HTTP redirects to HTTPS

Enter HSTS header.

As soon as you have visited the page once, the browser will attempt to connect using HTTPS. This principle is called “trust on first use” and prominently used in SSH. The above attack is impossible now because, after trust was established, the first request is already sent using HTTPS.

History & Support

The HSTS header is defined in RFC 6797, which was published as Proposed Standard in November 2012 by the IETF. The first Draft version was published in June 2010.

I did not find evidence of this, but it is said the Moxie’s talk New Tricks For Defeating SSL In Practice on Black Hat 2009 was the reason for the development of the HSTS header.

Nowadays, all major browsers support the HSTS header. The first browser to implement the security feature was Chrome in 2010. They even implemented it before the first IETF draft was published - don’t ask me how. The last popular browsers to implement it were Internet Explorer and Edge in 2015. I compiled the support dates into the following timeline:

Timeline of browsers supporting HSTS header and its RFC definition. 2009: ForceTLS Firefox Extension; 2010 Chrome and first RFC draft; 2011 Firefox; 2012 Opera, Chrome on Android, RFC 6797 published; 2013 Safari for Mac and iOS; 2015 Internet Explorer and Edge.

Timeline of browsers supporting HSTS header and its RFC definition

Before the official support and IETF definition, there was an extension for Firefox called ForceTLS, which basically had the same functionality. If servers provided an X-Force-TLS header, the extension modified request to be sent using HTTPS.

I find it hard to believe that basically every public network up to 2010 was completely vulnerable to SSL Stripping. But that’s the typical a posteriori view on security vulnerabilities, I guess.


Let’s take a look at the parameters that the HSTS header supports:

  1. max-age: The time, in seconds, that the browser should remember that a site is only to be accessed using HTTPS. Time spans shorter than 1 year (31536000s) are inadequate. A value of 2 years (63072000s) is recommended.1

  2. includeSubDomains: If this optional parameter is specified, the rule applies to all of the site’s subdomains. This means, if the header is returned on, it will hold for but not for

  3. preload: This parameter is not defined in the RFC. It has no direct influence on the behavior of the browser. Instead, a site that sends the preload directive is considered to be requesting inclusion in Google Chrome’s preload list. This is a list of domains that are hard coded into Chrome as being HTTPS only. But other browsers also rely on that list.

You can check the status and eligibility of domains on To get added to the preload list, a max-age of 2 years and the use of includeSubDomains are required. Note that it is not advised to include the preload directive by default as the removal from the list is a lengthy process.

For the sake of completeness, here is the syntax of an exemplary HSTS header:

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

Privacy Issues

Did you know that the HSTS header can be used as a tracking mechanism? Yes, you heard right. The header that ensures you are browsing the web via HTTPS has a privacy issue.

In fact, the HSTS header can be used as a browser fingerprint. This means, it allows to recognize a browser after its first visit. This behavior makes it possible to track users even if they deleted their cookies for the site in question.

It works like this:

  1. You visit The back end creates a 32-bit identifier: Your fingerprint.

    The site now requests 32 subdomains - If the according bit in the identifier is 1, the response contains an HSTS header.

    The friendly user’s browser sends a request to The malicious server responds with an HSTS header. Next, the friendly user requests The malicious server responds without an HSTS header. This goes on until is requested, which again returns an HSTS header.

    Step 1: Creating the browser fingerprint

  2. When you revisit the site, it includes images from several subdomains: -

    If an HSTS header has been stored, the request will be upgraded to HTTPS. The back end keeps track of the requests that are made using HTTPS and thus can restore the identifier.

    32 bits already allow the distinction of 4 billion users.

    Same scenario as above. The friendly user’s browser upgrades to HTTPS. The malicious server notes a 1 for the first bit. Next, the friendly user requests, which is not upgraded. The malicious server notes a 0. Finally the request to is upgraded again.

    Step 2: Retrieving the browser fingerprint

As far as I have found out, this attack vector was already identified in January 2010.


To prevent this attack, browser developers can intervene during the creation and the retrieval process. Apple’s WebKit browser mitigates the creation process as follows.2

It permits the HSTS state to be set only for the loaded hostname or the Top Level Domain + 1 (TLD+1). This means, if you visit, only HSTS headers for or for can be set. This would require victims to visit to in sequence, for example via redirects.

Apple’s argument is that this behaviour is perceptible by the user and thus unacceptable. To further mitigate the issue, WebKit caps the number of redirects that can be chained together.

Mixed Content

During the retrieval process, a security measure that makes tracking via HSTS impracticable is the restriction of mixed content.

When a page that is called using HTTPS includes content using HTTP, this is called a mixed content page.

There are two types of mixed content: passive and active content.

  • Passive contents are images <img>, objects <object> and audio <audio> or video <video> files.
  • Active contents are for example scripts <script>, iframes <iframe>, and XMLHttpRequests.

Most browsers prevent mixed active content either by blocking requests or by upgrading them to HTTPS. Chrome v110 currently upgrades passive mixed content by default. Firefox v110 does this experimentally in its current Nightly versions.3

Thus, the above tracking scenario might be history soon - even for Firefox users. This seems pretty late considering that RFC6797 already described this scenario as “Creative Manipulation of HSTS Policy Store” in Section 14.9.

Using the HSTS header in its original intention creates a tradeoff between privacy and security. However, browser improvements seem to reduce the privacy issue more and more.

In my opinion, security outweighs privacy in this case. I’d recommend to keep HSTS data in your browser for the sake of security.

Implementation Issues

It is one thing to describe a security measure in theory, but quite another to implement it securely. I fiddled around a bit with a Flask server and summarized the most interesting observations for you.

1. The limited number of HSTS entries

Firefox stores HSTS headers in a file called SiteSecurityServiceState.txt. Now hold on because it’s getting rough: Up to the current version (v113, March 2023) this file is limited to 1024 entries.

On Linux, the file storing HSTS entries can be found here:


You can check the number of entries with a simple command:

$ wc -l ~/.mozilla/firefox/{profile}/SiteSecurityServiceState.txt
1024 /home/[...]/SiteSecurityServiceState.txt

I used my Firefox instance for around two years on my current computer and have reached the limit of 1024 already.

When the file is full, Firefox seems to discard entries based on a score that depends on how frequently the site in question was visited. I did not find official documentation on this matter. The best source is a talk from Black Hat 17. As I don’t know the exact algorithm, this is hard to grasp, but a possible outcome is that once your file has reached the limit, Firefox effectively does not store any further HSTS headers as new ones permanently override each other. I find this pretty bad and filed a bug report.

I also had the idea to create a site that sets so many HSTS headers such that every other stored HSTS header is overridden. However, this was impracticable: I did set HSTS headers for 10.000 subdomains but at the end but only 248 of those were stored. The others were overridden by the subsequent headers. Nevertheless, this means that I was able to override about 25% of the HSTS headers stored on my system.

2. An integer overflow in the expiry date

The expiry date of Firefox’s HSTS headers is was prone to an integer overflow.

As we have learned above, Firefox stores HSTS headers in a file called SiteSecurityServiceState.txt.

This is an exemplary entry from that file:^	22 19426 1741556827926,1,1

I did not find any official documentation but entries seem to have the following structure:

{target}^{context}:HSTS {score} {lastVisit} {expiry},{state},{includeSubDomains}
  • target defines for which domain the HSTS header was set:

  • context is the domain the target was called from. In the example this is

  • score is the number of days that the domain in question was visited

  • lastVisit is the day the domain was last visited (counted in days since 1970-01-01)

  • expiry is the expiry Unix timestamp in milliseconds

  • state can either be unset (0), set (1) or knockout (2) which overrides preload information

  • includeSubDomains reflects whether the according flag was set during HSTS transmission

A value that raised my interest was the expiry timestamp, as it can be manipulated by the server’s max-age value.

To calculate the expiry timestamp, the max-age has to be multiplied by 1000 to convert it from seconds to milliseconds. The calculation looks as follows:

$\text{expiry [in ms]} = \text{max-age [in s]} \cdot 1000 + \text{currentUnixTimestamp [in ms]}$

At that moment my hacker’s mind spoke up:

Scene from Django Unchained where Dr. Schultz makes a ridiculous offer to Mr. Candie such that he is forced to consider it. Caption: If I set a max-age so ridiculous, you’d be forced to overflow? Who knows what could happen?

Image source: Django Unchained, © 2012 Columbia Pictures Industries, Inc.

I returned a ridiculous high value, which caused a negative expiry date in the HSTS entry. Apparently the expiry date is stored as a signed 64-bit integer.

To prove that I understood what was going on, I challenged myself to set an expiry value of $-1000.$

I programmed my server to return the following header:

Strict-Transport-Security: max-age=18446742396180544

The max-age value was calculated like this: $\text{max-age} = \frac{(2^{64} - \text{currentUnixTimestamp} - 1000)}{1000}$

The calculation should be as follows:

$\text{expiry} = \frac{(2^{64} - \text{currentUnixTimestamp} - 1000)}{1000} \cdot 1000 + \text{currentUnixTimestamp}$

$= 2^{64} - \text{currentUnixTimestamp} - 1000 + \text{currentUnixTimestamp}$

$= 2^{64} - 1000 \stackrel{\text{in two’s complement}}{=} -1000 $

And indeed, the SiteSecurityServiceState.txt file now contained the value $-1051$.

$ grep "hsts.local" ~/.mozilla/firefox/{profile}/SiteSecurityServiceState.txt
0000.hsts.local^partitionKey=%28http%2Chsts.local%29:HSTS 0 19415 -1051,1,0

Note that the $51ms$ difference probably come from the time taken for computation and transmission.

As I found that there is no way to exploit this, I filed a public bug report. It was ranked priority P1 within 15 days and fixed two days later. Firefox v113 is not prone to the overflow anymore.

3. HSTS requires HTTPS and a valid certificate chain

During the setup of my HSTS lab, I wondered why the header does not seem to be obeyed. At that point, I was using the ad hoc ssl_context of Flask. This creates a temporary self-signed certificate and leads to a Warning page when visited in the browser.

I was able to resolve the issue by creating my own CA and importing it into Firefox. The certificate I configured in Flask was signed by that CA.

A further thing to notice is that HSTS headers sent over HTTP connections are not obeyed at all. This is already defined in the RFC 6797:

“An HSTS Host MUST NOT include the STS header field in HTTP responses conveyed over non-secure transport.”

RFC6797 Section 7.2

I think these requirements totally make sense. Websites with invalid certificates are not trustworthy in a technical sense. Thus, the HSTS header might be manipulated and should not be trusted.

Only accepting HSTS headers that are sent via HTTPS also makes sense as this proves that the HTTPS server is indeed working. If HSTS headers would be accepted over an HTTP connection, this could lead to inoperable servers.

If you want to configures HSTS correctly, ensure to

  • deploy valid certificates.
  • redirect HTTP traffic to HTTPS.
  • return the HSTS header via HTTPS.

4. Modern browsers ignore HSTS headers in third-party loads

Third-party resources are heavily used across the web. Technically, third-party resources are hosted on a different domain than you’re currently on. However, subdomains are considered the same party as the base domain.

The majority of third-party resources are JavaScript, images and HTML. 4 If a website includes resources from a third-party, these are loaded via separate HTTP(S) requests of your browser. As discussed above, such HTTPS responses may contain an HSTS header to force the browser to upgrade HTTP to HTTPS.

But here comes the caveat: Third-party loads are not allowed to set HSTS states since Firefox 91.

This was a change implemented into Firefox due to Bug 1701192. The reporter of this bug actually recognized that Firefox only stores up to 1024 HSTS entries, as we’ve discussed above.

One of the reasons why these 1024 entries are quickly reached was identified to be third-party loads. So instead of fixing the real cause of the problem - a too small limit - the number of HSTS headers that Firefox stores was reduced by ignoring them in third-party loads.

The obvious drawback of this is reduced security. But as discussed earlier, the HSTS header is a tradeoff between privacy and security. This is also the case here.

The reason is that if you visit my website, I am able to find out which other third-parties you have visited like this:

I embed an image from http://third․party.tld:443/img.png

If you have already visited the third-party HSTS would upgrade the request to HTTPS, resulting in a successful load. Otherwise, an HTTP request is sent to a well-known HTTPS port, which most likely results in an error. This result is distinguishable for me.

To prevent this scenario, WebKit ignores HSTS upgrades for third-party loads. As discussed above, Firefox does this as well, but for a different reason: to not reach the HSTS entry limit too fast.

The only measure that can bail us out of the tradeoff between privacy and security is making the HTTPS-first mode in browsers a default setting.

Bypass HSTS with HTTP Header Injection

Ambiguity of HTTP headers is a major problem that, for example, introduced the fascinating technique of HTTP request smuggling. Contradictory header values hold the potential for misinterpretation and thus for the emergence of vulnerabilities. I took a look at this problem for the Strict-Transport-Security header and found an attack vector that I did not know before.

We have to consider two cases:

  1. Multiple headers sent simultaneously in the same HTTP response

    RFC6797 already defines how browser should react in this case:

    “If a [user agent] UA receives more than one STS header field in an HTTP response message over secure transport, then the UA MUST process only the first such header field.”

    RFC6797 Section 8.1

During my tests, Firefox and Chromium correctly followed this instruction.

By the way, I often see the use of multiple HSTS headers during pentests. Mostly, this misconfiguration emerges if a reverse proxy sits in front of the actual web server and both, reverse proxy and web server, set HSTS headers.

  1. Conflicting headers sent in consecutive HTTP responses

    RFC6797 also has an answer on this one:

    “Only the given HSTS Host can update or can cause deletion of its issued HSTS Policy. […] UAs cache the “freshest” HSTS Policy information on behalf of an HSTS Host.”

    RFC6797 Section 5.3

Let’s think about this for a moment. Of course, a host should be able to update its own HSTS config later in time. Actually, when you are browsing a website, its HSTS expiry date must be continuously upgraded anyway because time goes on.

Still, basing your trust on the freshest header feels wrong from a security perspective. Instead of the principle “trust on first use” the security of HSTS actually depends on the last value sent. Due to the secure connection between client and server, values that are sent by the server are assumed to be trustworthy.

But this is not always the case. Attacks like HTTP header injection allow to manipulate headers sent by the server. Often such attacks require user interaction, like clicking on a malicious link containing a value that is then reflected in the response’s headers.

A payload might look like this:

?fileName=a\r\nStrict-Transport-Security: max-age=0

If this value is reflected before the actual HSTS header, it would delete the current HSTS entry because:

“Specifying a zero time duration signals the UA to delete the HSTS Policy (including any asserted includeSubDomains directive) for that HSTS Host.”

RFC6797 Section 5.3

As the user is still in an HTTPS context, this is not exploitable. But what if we changed that? Exploiting the same vulnerability as above, a response that redirects the user to HTTP via JavaScript could be returned. Now an attacker with a machine in the middle can intercept the request as described above.

Lab: HSTS Header Injection

To make the attack more comprehensible, I published a simple lab on GitHub that simulates it.

The combination of the following three properties of HSTS makes the exploit possible:

  1. The most recent policy overrides previous ones
  2. A max-age of 0 deletes the stored policy
  3. Only the first HSTS header field in a response is processed

The idea is simple: Using HTTP header injection, inject an HSTS header with max-age=0 prior to the original HSTS header. This will delete the HSTS policy for the host in question. Add-on: Redirect the victim to HTTP via JavaScript in the same response.

Let’s say the endpoint https://hsts.local/vuln?param=value is vulnerable to HTTP header injection. A response of the server looks like this:

The response of the server contains the header X-Vulnerable-Header: param=value. The value is reflected from the query parameter in the URL. After that header is the regular Strict-Transport-Security header. The body contains some HTML.

X-Vulnerable-Header reflects user input in response headers

By appending CRLFs, we can inject headers into the response.

A payload of value%0d%0aStrict-Transport-Security%3a+max-age%3d0 leads to this:

After the X-Vulnerable-Header there is now the injected Strict-Transport-Security header with max-age=0. It precedes the regular Strict-Transport-Security header, which is invalidated by the injected one.

Newlines are not filtered and make the server vulnerable to HTTP header injection

This effectively deletes the HSTS policy. As the victim is in an HTTPS context, subsequent requests still will be sent via HTTPS. By appending the following payload, we redirect the victim to HTTP:


After the injected Strict-Transport-Security header there are now two newlines, which mark the start of the body. The body contains a JavaScript redirect to http://hsts.local/?cb=3401833577

A JavaScript redirect is injected into the response body

Note that the cb value is just a random cache buster to ensure that the browser does not respond from its cache. Now the secret cookie of the web application is sent via HTTP, as can be seen in Wireshark. Another interesting piece of information might be an Authorization header.

Screenshot of Wireshark showing that the secret cookie is transferred in plain text.

Wireshark shows that the cookie is transferred in plain text

This wraps up my blog post on HSTS. If you enjoyed it, share it with your friends and follow me on Mastodon or Twitter!

Have a great day!