Matt Laux

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:

HeaderValueDescription
Set-Cookienext-auth.session-tokenSends cookies from the server to the user agent
Content-Typeapplication/json; charset=utf-8Indicates the resource's media type
ETag"fd1fidrn3"Identifies a specific resource version
Content-Length35An entity header indicating the size of the entity-body, in bytes
VaryAccept-EncodingDetermines how to match future request headers to decide whether the cached response can be used rather than requesting a fresh one
DateWed, 17 Aug 2022 16:03:56 GMTA general header containing the date and time the message was sent
Connectionkeep-aliveA general header specifying whether the current network connection will stay open once the current transaction finishes
Keep-Alivetimeout=5Number 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.

HeaderValueDescription
X-DNS-Prefetch-ControlonAllows browsers to proactively perform domain name resolution on links
Strict-Transport-Securitymax-age=31536000; includeSubDomains; preloadLets websites tell a browser that they should only be accessed via HTTPS
X-XSS-Protection1; mode=blockStops pages from loading when they detect reflected cross-site scripting (XSS) attacks
X-Frame-OptionsdenyUsed to avoid clickjacking attacks by making sure their content is not embedded into other sites
X-Content-Type-OptionsnosniffCan be used to opt out of MIME type sniffing a response away from the declared content-type
Referrer-Policyno-referrerControls how much referrer information is included with requests
Content-Security-Policydefault-src 'self' .......Added layer of security that helps to detect and mitigate certain types of attacks
X-Download-OptionsnoopenDisables the option to open a file directly on download
Origin-Agent-Cluster?1Instructs the browser to prevent synchronous scripting access between same-site cross-origin pages
X-Permitted-Cross-Domain-PoliciesnoneRestricts other domains from loading your site's assets
Cross-Origin-Embedder-PolicycredentiallessEnables cross-origin isolation by preventing credentials from being sent in cross-origin requests
Cross-Origin-Opener-Policysame-originPrevents documents from sharing a browsing context group with cross-origin documents
Cross-Origin-Resource-Policysame-originAdditional protection against certain requests from other origins
Permissions-Policyaccelerometer=(), ...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:

;