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! ⤵
Basics
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 kpwn.de
into the address bar of your browser and hit Enter, at first an HTTP request to http://kpwn.de
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 https://kpwn.de
and subsequent requests will be encrypted.

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.

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
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.
Parameters
Let’s take a look at the parameters that the HSTS header supports:
-
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 -
includeSubDomains
: If this optional parameter is specified, the rule applies to all of the site’s subdomains. This means, if the header is returned ona.kpwn.de
, it will hold forb.a.kpwn.de
but not forkpwn.de
. -
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 hstspreload.org.
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:
-
You visit
https://kpwn.de
. The back end creates a 32-bit identifier: Your fingerprint.The site now requests 32 subdomains
https://0.kpwn.de/
-https://31.kpwn.de/
. If the according bit in the identifier is 1, the response contains an HSTS header.Step 1: Creating the browser fingerprint
-
When you revisit the site, it includes images from several subdomains:
http://0.kpwn.de/a.png
-http://31.kpwn.de/a.png
.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.
Step 2: Retrieving the browser fingerprint
As far as I have found out, this attack vector was already identified in January 2010.
Mitigation
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 b.a.kpwn.de
, only HSTS headers for b.a.kpwn.de
or for kpwn.de
can be set.
This would require victims to visit 0.kpwn.de
to 31.kpwn.de
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:
~/.mozilla/firefox/{profile}/
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:
assets.infosec.exchange^partitionKey=%28http%2Cinfosec.exchange%29:HSTS 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: assets.infosec.exchange -
context
is the domain the target was called from. In the example this is infosec.exchange -
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:

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.”
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:
-
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.”
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.
-
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.”
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.”
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:
- The most recent policy overrides previous ones
- A max-age of 0 deletes the stored policy
- 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:

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:

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:
%0d%0a%0d%0a<script>location=%22http://hsts.local/%3fcb%3d3401833577%22</script>

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.

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!