How to implement proper HTTP security headers with Next.js
Every week you hear about various data breaches ranging from top companies such as Microsoft all the way down to small mom and pop businesses. These data breaches cost the business significant time, energy, and money. Not to mention the damage in trust and reputation.
In addition, data breaches can be a nightmare for your customers to handle. As a web developer, you have a professional and moral obligation to ensure that you handle people's data securely.
Overview
In this guide, I will reveal how you can add and modify HTTP security headers to your Next.js web application. Many inexperienced or lazy developers will just settle with the default HTTP security headers. However, this opens up serious security risks in your application.
Properly adding HTTP security headers to a Next.js web application is a quick process that significantly improves the security.
Throughout this guide I will follow best practices as recommended by OWASP's Secure Headers Project and Next.js' documentation.
If there are any of these headers that you would like to read more about, mdn web docs is one of the best resources.
Table of Contents
Default HTTP security headers
If you do not add any HTTP security headers, browsers will still send some default HTTP headers. However these HTTP headers are very minimal and do not provide strong protection.
Below is an example of the default HTTP headers a Next.js web application includes:
Header | Value | Description |
---|---|---|
Set-Cookie | next-auth.session-token | Sends cookies from the server to the user agent |
Content-Type | application/json; charset=utf-8 | Indicates the resource's media type |
ETag | "fd1fidrn3" | Identifies a specific resource version |
Content-Length | 35 | An entity header indicating the size of the entity-body, in bytes |
Vary | Accept-Encoding | Determines how to match future request headers to decide whether the cached response can be used rather than requesting a fresh one |
Date | Wed, 17 Aug 2022 16:03:56 GMT | A general header containing the date and time the message was sent |
Connection | keep-alive | A general header specifying whether the current network connection will stay open once the current transaction finishes |
Keep-Alive | timeout=5 | Number of seconds an idle connection will remain open before it is closed |
As you can see, almost none of these headers address potential security concerns. Clearly you need to take control of the HTTP headers process.
HTTP security headers to add / adjust
Below is a list of headers that you will be adding. Most likely your web application does not need all of these and some web applications may require additional headers. This is a good starting point and feel free to modify this list as needed.
Header | Value | Description |
---|---|---|
X-DNS-Prefetch-Control | on | Allows browsers to proactively perform domain name resolution on links |
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | Lets websites tell a browser that they should only be accessed via HTTPS |
X-XSS-Protection | 1; mode=block | Stops pages from loading when they detect reflected cross-site scripting (XSS) attacks |
X-Frame-Options | deny | Used to avoid clickjacking attacks by making sure their content is not embedded into other sites |
X-Content-Type-Options | nosniff | Can be used to opt out of MIME type sniffing a response away from the declared content-type |
Referrer-Policy | no-referrer | Controls how much referrer information is included with requests |
Content-Security-Policy | default-src 'self' ....... | Added layer of security that helps to detect and mitigate certain types of attacks |
X-Download-Options | noopen | Disables the option to open a file directly on download |
Origin-Agent-Cluster | ?1 | Instructs the browser to prevent synchronous scripting access between same-site cross-origin pages |
X-Permitted-Cross-Domain-Policies | none | Restricts other domains from loading your site's assets |
Cross-Origin-Embedder-Policy | credentialless | Enables cross-origin isolation by preventing credentials from being sent in cross-origin requests |
Cross-Origin-Opener-Policy | same-origin | Prevents documents from sharing a browsing context group with cross-origin documents |
Cross-Origin-Resource-Policy | same-origin | Additional protection against certain requests from other origins |
Permissions-Policy | accelerometer=(), ... | Restricts unauthorized access or usage of browser/client features |
NOTE: OWASP recommends the Cache-Control
header be set to no-store, max-age=0
. However, Next.js has its own method of handling the Cache-Control
header so you will leave that one out. More can be read in Next.js' documentation
NOTE: OWASP recommends the Pragma
header be set to no-cache
. Similar to Cache-Control
you will leave this header off as Next.js handles its' own cache headers.
NOTE: OWASP recommends the Clear-Site-Data
header be set to "cache", "cookies", "storage"
. You DO NOT want to set this header for all paths. This header should only be set when a user is deleting their profile or maybe when a user logs out of their session.
Creating a Content-Security-Policy (CSP)
The Content-Security-Policy header can be a little tricky. It does not have a handful of options like most other headers. It takes a custom string that must be created by you. This allows it to be fully backward-compatible and allows greater compatibility between servers and browsers.
A good example CSP is shown below:
const contentSecurityPolicy = `
default-src 'self' ${isProd ? '' : "* data: 'unsafe-eval' 'unsafe-inline'"};
base-uri 'self';
child-src 'self';
font-src 'self' https: data:;
form-action
'self'
http://localhost:3000/api/auth/signin/auth0;
frame-ancestors 'none';
img-src 'self' data:;
object-src 'none';
script-src 'self' ${isProd ? '' : "* data: 'unsafe-eval' 'unsafe-inline'"};;
script-src-attr 'none';
style-src 'self' https: 'unsafe-inline';
upgrade-insecure-requests
`;
NOTE: If you use any OAuth providers or a service like Stripe you will need to add their respective domains to the form-action attribute. I have included an example of adding Auth0, which would need to be adjusted in production. You will also need to allow unsafe-eval
and unsafe-inline
to be used with with default-src
and script-src
. The isProd
condition included above will ensure that only self
is allowed for these attributes in production.
As you implement your content-security-policy, keep an eye on the console section of your developer tools. Eventually you will receive an error that a certain domain violates the policy. At that point, you will just need to add the domain to this policy.
When adding domains to your CSP, you want to keep the domains as specific as possible and limit the use of wildcards. If your domain is too broad it can open you up to potential attacks.
Permissions-Policy
The full Permissions-Policy to add can be seen below:
accelerometer=(),autoplay=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),geolocation=(),gyroscope=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(),publickey-credentials-get=(),screen-wake-lock=(),sync-xhr=(self),usb=(),web-share=(),xr-spatial-tracking=()
Adding HTTP security headers to your app
Now that you have reviewed the necessary headers and their content, you can actually add them to your Next.js web application. If you have not already, you need to add a next.config.js
file in your root directory. In your next.config.js
file you will need to add the following code:
/**
* False if Node env is not production. True if Node env is production
*/
const isProd = process.env.NODE_ENV === 'production';
/**
* Custom content security policy to be used with content security policy header
* default-src and script-src must allow "unsafe-eval" and "unsafe-inline" to properly work in development environment with oAuth providers
* form-action includes auth0 to support oAuth logins
*/
const contentSecurityPolicy = `
default-src 'self' ${isProd ? '' : "* data: 'unsafe-eval' 'unsafe-inline'"};
base-uri 'self';
block-all-mixed-content;
child-src 'self';
font-src 'self' https: data:;
form-action
'self'
https://dev-fsanfi39f.us.auth0.com
http://localhost:3000/api/auth/signin/auth0
frame-ancestors 'none';
img-src 'self' data:;
object-src 'none';
script-src 'self' ${isProd ? '' : "* data: 'unsafe-eval' 'unsafe-inline'"};;
script-src-attr 'none';
style-src 'self' https: 'unsafe-inline';
upgrade-insecure-requests
`;
/**
* Security headers to be used with all API routes in application
*/
const securityHeaders = [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains; preload',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'X-Frame-Options',
value: 'deny',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'no-referrer',
},
{
key: 'Content-Security-Policy',
value: contentSecurityPolicy.replace(/\s{2,}/g, ' ').trim(), // Removes whitespace in CSP
},
{
key: 'X-Download-Options',
value: 'noopen',
},
{
key: 'Origin-Agent-Cluster',
value: '?1',
},
{
key: 'X-Permitted-Cross-Domain-Policies',
value: 'none',
},
{
key: 'Cross-Origin-Embedder-Policy',
value: 'credentialless',
},
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
{
key: 'Cross-Origin-Resource-Policy',
value: 'same-origin',
},
{
key: 'Permissions-Policy',
value:
'accelerometer=(),autoplay=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),geolocation=(),gyroscope=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(),publickey-credentials-get=(),screen-wake-lock=(),sync-xhr=(self),usb=(),web-share=(),xr-spatial-tracking=()',
},
];
const nextConfig = {
pageExtensions: ['ts', 'tsx', 'js', 'jsx'], // include ts and tsx if using typescript
poweredByHeader: false, // removes x-powered-by header to restrict sensitive info from attackers
async headers() {
return [
{
// Apply these headers to all routes in your application
source: '/:path*',
headers: securityHeaders,
},
{
// Only applies Clear-Site-Data header to deleteUser path
// Above headers still apply
source: '/api/deleteUser',
headers: [
{
key: 'Clear-Site-Data',
value: '"cache", "cookies", "storage"',
},
],
},
];
},
};
export default nextConfig;
NOTE: The poweredByHeader: false
line removes the X-Powered-By header. This header is a security risk as it tells attackers what programs and frameworks you used to create your web application.
More information about adding HTTP headers can be found in the Next.js Headers Documentation.
You can now test your various pages and API routes to ensure that the proper HTTP headers are being set. I personally use Postman to test the HTTP headers.
References
Hopefully your headers work perfectly by implementing this guide. Unfortunately, everyone's web application is different and HTTP headers can be a tricky topic. It is very likely that you encounter issues even with this guide.
The following resources are great places to start troubleshooting: