HTTP Headers for fast & secure static sites

An introduction to key HTTP response headers for speed and security, with implementation guides for Netlify & CloudFlare

This website is powered by Netlify, it also has Content-Security-Policy and cache-control HTTP response headers to improve client security and performance. In this short article I'll describe the key response headers you should be aware of and how how I configured them on Netlify. The configuration is simple when you know how, but it can take a while to work out!

Intro to HTTP Headers

I am discussing HTTP response headers in this article. These are a set of key-value pairs sent by a web server at the beginning of a response, separated by a colon. The headers are directives which tell HTTP clients how to treat responses (where clients can be CDNs, proxies and web browsers). No headers are required by the HTTP protocol, but status is a requirement for an HTTP response.

You can see response headers in your browser developer tools, just select a response and view the headers:

Screenshot of response headers from chrome developer tools
Response headers shown in Chrome Developer Tools for a page on this site.

In the following sections we will walk through some of the key use cases for response headers, when to use them and what they look like.


Content headers are critical for content that can be negotiated between client and server, such as image formats and encoding algorithms. The following response headers indicate that the response body is binary encoded using gzip, and that the content is an HTML document with a UTF8 charset:

Content-Encoding: gzip
Content-Type: text/html; charset=utf-8

Thankfully your CMS or CDN should take care of these for you. One thing to look out for is support for gzip and brotli compression. If there is no Content-Encoding header on text files (HTML, CSS, JS etc.) than your static assets are being sent uncompressed. Some providers (such as Heroku) do not compress content for you, so you'll need to implement it in your application.


There are a wide range of response headers which impact how browsers cache content. Caching static content is important for delivering fast page loads: the fastest request is the one that is never made. Some content should never be cached, however, such as responses from a login or balance API or pages with very dynamic content. Response headers provide fine-grained control over how HTTP clients cache content. Caching headers should be present on every response, below are some examples of the Cache-Control header for a number of scenarios:

Cache-Control: public, max-age=31536000, immutable # any cache can store this for a year use it without revalidating
Cache-Control: public, max-age=2628000 # any cache can store this for a month
Cache-Control: private, max-age=3600 # the browser may store this for one hour
Cache-Control: no-store # no cache may store this at all

Unfortunately, Netlify defaults to Cache-Control: max-age=0, must-revalidate, public and an ETag header on all responses, instructing caches not to serve cached content without revalidating against the origin that asset has not changed. This can have a negative impact on performance, especially for users on high-latency connections, as a conditional GET request will be made for all resources. It does, however, mean that changes you make to your site are reflected on the web almost immediately. You can override default Netlify behaviour by setting the response header to blank, e.g. Etag = "". This might be particularly useful to prevent revalidation on assets such as webfonts, to prevent a flash of unstyled or invisible text while the page renders and revalidates the cached font files.

There are a bunch of headers which impact caching: Etag, Age, Last-Modified, Expires and Pragma are all considered by the browsers when determining cache state, although Cache-Control supersedes some behaviours such as Expires. Further reading: HTTP Cache Headers on Key CDN.

Content Security Policy

There are a number of headers which enable security features in browsers. The most exciting (for me) header is Content-Security-Policy (CSP), this gives the browser a list of approved content sources, and what they are allowed to do.

A number of recent public cases of Magecart attacks, data skimming and compromised third-parties make the CSP header a must-have for any transactional site. CSP will also block any malicious browser extensions from injecting ads onto your pages which slow the experience down. There is no reason to not set up the CSP header on your static site.

CSP directives align roughly with HTML5 and JavaScript functionality, a full list of directives is included below:

CSP Directive HTML / JS Features
default-src *
connect-src fetch(), WebSocket(), etc.
style-src <link rel="stylesheet">
script-src <script>
form-action <form>
font-src @font-face
child-src <iframe>, Worker()
object-src <object>, <embed>
media-src <video>, <audio>
img-src <img>
manifest-src <link rel="manifest">

CSP also allows violations reports to be sent to specified URLs, using a deprecated report-uri directive and its replacement, the Reporting API, via a report-to header. CSP headers should only be sent on document responses, i.e. the HTML file.

An example CSP directive for this site is shown below. It allows some inline scripts (such as the Google Analytics snippet), scripts from Twitter and embedded content from Al other content will be blocked, and a CSP violation report will be sent to for analysis.

default-src 'self';
script-src 'self' 'sha256-6/iD6t0SQvujSE2Zwae43Lq7XJSEA98rpBEWsYJd5RU=' ...;
img-src 'self' data: * * *;
connect-src 'self';
style-src 'self' 'unsafe-inline';
child-src 'self' *;
worker-src 'self';
report-to report-uri;

Inline scripts are generally a bad idea, but CSP allows us to define which should be allowed to excute by either providing a nonce - a unique identifier, or a hash of the script content. As pointed out by Amier Saleh on twitter, it is important not to use nonce on static sites, otherwise an attacker can simply copy the nonce string to inject their <script>! As such, we must use hashes for inline scripts. Helpfully, Chrome will tell you what the hash should be when it finds an inline script which violates your policy:

Screenshot of suggested hash value from chrome developer tools
Suggested hash value shown in Chrome Developer Tools for inline script.

Further reading: HTTP Security Headers on keycdn and Google's CSP FAQs


HTTP Strict Transport Security (HSTS) is a simple header which tells a browser that your site should not be accessed via non-encrypted (i.e. http) connections. This prevents https downgrade attacks and ensures that your visitors' information is sent securely.

While services like Netlify and CloudFlare offer https upgrade via redirects, the hsts header is a belt-and-braces approach to ensure that https is always used. This will prevent potential man-in-the-middle attacks which attempt to steal cookie data by making requests to non-existent non-secure subdomains. All non-secure requests will be automatically upgraded to https before the requests are made, preventing any data being accidentally sent over plaintext.

If you enable an HSTS header, ensure that your HTTPS configuration is correct! Browsers which have seen the header will refuse to connect over http for the duration of the max-age directive, including if the certificate is not trusted for any reason. Because of the risk to the availability of your site, it may be worth using a short max-age directive. Below is an example which tells the browser to enforce HTTPS connections across all subdomains for 24 hours. The recommended max-age is two years. A preload directive may be added to the header, which hints to browsers that this domain should always be accessed over https, this can be important as HSTS headers are only valid on sites served over secure connections.

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

Further reading: Understanding HSTS by Troy Hunt and

Network Error Logging

Network Error Logging (NEL) instructs browsers to send reports to a defined endpoint when it fails to load a page. NEL reports are sent in a number of situations, from DNS resolution failures to 5xx and 4xx server responses. This opens a whole new world of reporting, some of which has previously been a black-box to website owners. Note in the example below I have added a Reporting API header which defines the reporting group for the NEL header to use. NEL headers should only be sent on document responses, i.e. the HTML file.

Report-To: {"group":"report-uri","max_age":31536000,"endpoints":[{"url":""}],"include_subdomains":true}
NEL: {"report_to":"report-uri","max_age":31536000,"include_subdomains":true}

Further reading: Network Error Logging: Deep Dive by Scott Helme

Feature Policy

Feature Policy is like CSP, but for web platform features. This header allows you to disable access to features such as the camera, microphone and geolocation on your site. This prevents any malicious attempts to use these features by third-party scripts.

A reasonable assumption can be made that you know whether any part of your site needs access to these features, and if not you should set a feature policy which disables access:

Feature-Policy : camera 'none'; geolocation 'none'; microphone 'none'

Policy headers can be split out, and like CSP you can limit access to the parent page or to specific domains:

Feature-Policy: unsized-media 'none'
Feature-Policy: geolocation 'self'
Feature-Policy: camera *;

Further reading: Introduction to Feature Policy on Google Web Fundamentals

Client Hints

Client Hints instruct the browser to send information about itself along with requests. This includes information about the current connection, device memory, screen resolution and whether the user has opted in to features such as Lite Pages. This can greatly simplify the deployment of responsive images and dynamic content based on device capability. I do not currently use this data for anything dynamic, but it is trivial to see how this can be integrated into an image delivery solution. Client hint headers should only be sent on document responses, i.e. the HTML file.

Accept-CH: Downlink,RTT,Device-Memory,Save-Data,DPR,Width

Further reading: Automating Resource Selection with Client Hints on Google Web Fundamentals


Headers generally have to match the defined set of header keys, but there is an allowance for custom headers. All custom headers must be prefixed with x-, but then can be anything you like. These are often used to include debug information, such as which server served the content, what policies were applied and de facto standard headers such as X-Forwarded-For used by CDNs. If you have a site, you may see these humorous headers:

x-hacker: If you're reading this, you should visit and apply to join the fun, mention this header.
x-nananana: Batcache

Configuring Headers on Netlify

So now we know what headers are used for, let's configure them for a Netlify site. I'll use the netlify.toml file-based configuration in this example, but it could be translated easily in to the _headers configuration. Unfortunately, there is currently no way to set a header on a document but not on static assets. As such, we have to set our security headers on all responses. This is not ideal, and in the H/1.1 world would be untenable due to the overhead of the header data. Luckily, H/2 compresses header data and uses a shared dictionary which reduces the impact of this Netlify limitation. If you have Cloudflare in front of your Netlify configuration, this worker script will strip unnecessary headers from static assets.

Note that header values which include double quotes must be escaped with toml multiline string markers: triple single quotes '''.

# netlify.toml

# Set the default header to the one we want for documents
for = "/*"
cache-control = "public,max-age=60"
Referrer-Policy = "no-referrer-when-downgrade"
X-Frame-Options = "SAMEORIGIN"
X-Content-Type-Options = "nosniff"
Feature-Policy = "camera 'none'; geolocation 'none'; microphone 'none'"
Strict-Transport-Security = "max-age=2592000; includeSubDomains"
Accept-CH = "Downlink,RTT,Device-Memory,Save-Data,DPR,Width"
Report-To = '''{

NEL = '''{

Content-Security-Policy = '''
default-src 'self';
script-src 'self' 'sha256-i+rYvjE2MLQRpsO7Qygbp0RqJCgJE9pO2xyOIhf5LZE=' ...;
img-src 'self' data: * ...;
connect-src 'self';
style-src 'self' 'unsafe-inline';
child-src 'self' *;
worker-src 'self';
report-to report-uri;'''

# Override cache duration for assets with periods in the filename (i.e. static assets)
for = "/*.*"
cache-control = "public,max-age=360000"

Configuring Headers on CloudFlare

If you are using CloudFlare in front of GitHub Pages for your static site, chances are that your caching is less than ideal. The default cache duration for static assets on GitHub Pages is five minutes, and pages are not cacheable. CDNs will generally be transparent, so these cache headers will not be changed.

CloudFlare configuration allows you to set a standard cache duration across all assets with a simple dropdown selection. While this makes it easy to improve cache durations, there is no granularity to cache some assets for different durations.

Screenshot of CloudFlare UI to configure cache durations
Coarse cache control configuration in Cloudflare.

If you want more fine-grained control over headers, you will need to move your site away from GitHub pages or use CloudFlare workers to set custom headers. This is also required to set Report-To and Content-Security-Policy headers. Scott Helme has posted a recommended worker script to add important security headers: worker.js on GitHub.