All thoughts
Reading time
8 minutes
PublishedRevised
/
Hero image
Hero image

Securing Next.js SPAs: Headers 

CSP, CORS, CORB, TrustedTypes, Permissions Policy, HSTS and a ton of headaches.

Alright, lets talk security. Headers are an integral part of securing web apps, and this article will discuss how to set them in Next 15.

Let's split this endeavour into 4 sections:

But, before we dive in, let's outline several resources that are integral for understanding what headers we need and what they do.

  • Security Headers: use this to check what headers are present now, what needs improving. This website also has links to
  • Scott's Blog: the best resource to learn about different headers and what they do.
  • MDN: the place for official documentation regarding HTTP.
  • Next.js: for the official recommendation by developers of Next.js.

Instead of spending time copy-pasting info from these resources, I would rather redirect you to the proper channels. With that said, let's start!

Basic Headers

By "basic" I refer to headers that do no vary based on request. That is, they are present on every request to the origin.

Go to Security Headers and check what Headers you've already got, and what you need to add. Here is a short list of what I implement on mine:

  • Referrer-Policy
  • X-Content-Type-Options
  • Permissions-Policy
  • Cross-Origin-Embedder-Policy
  • Cross-Origin-Opener-Policy
  • Cross-Origin-Resource-Policy
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Strict-Transport-Security

To implement these, we can leverage next.config.ts async headers() function. Here is an example (you can check out the full next.config.ts used by this website here)

next.config.ts
import { NextConfig } from 'next';

const nextConfig: NextConfig = {
	...
	
	async headers() {
		return [{
			source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
			headers: [
				{
					key: 'Referrer-Policy',
					value: 'same-origin'
				},
				{
					key: 'X-Content-Type-Options',
					value: 'nosniff'
				},
				{
					key: 'Permissions-Policy',
					value: '...'
				},
				{
					key: 'Cross-Origin-Embedder-Policy',
					value: 'require-corp'
				},
				{
					key: 'Cross-Origin-Opener-Policy',
					value: 'same-origin'
				},
				{
					key: 'Cross-Origin-Resource-Policy',
					value: 'cross-origin'
				},
				{
					key: 'Access-Control-Allow-Methods',
					value: 'GET, POST, PUT, DELETE, OPTIONS'
				},
				{
					key: 'Access-Control-Allow-Headers',
					value: 'Content-Type, Authorization'
				}
			]
		}];
	}
};

export default config;

Notes:

Deprecated Headers

You may have noticed I do not list X-Frame-Options and X-XSS-Protection. The reason for that is these headers are deprecated and replaced by CSP.

Deprecated

X-Frame-Options: Deprecated and replaced by CSP frame-ancestors, which is talked about later in the CSP section.

X-XSS-Protection: This header can itself create XSS vulnerabilities in perfectly safe websites. Use CSP to allow for safer and tighter control if you do not plan to support legacy browsers.

Referrer-Policy

MDN: Referrer-Policy

I highly suggest reading A new security header: Referrer Policy and making an informed decision based on the information provided. In short, avoid

Here is a little diagram to help you decide ;)

Referrer Policy graph
Referrer Policy graph

COEP, COOP, CORP

Once again, read this awesome article COEP COOP CORP CORS CORB - CRAP that's a lot of new stuff!. It explains this trio far better than I ever will.

Varying Headers

By "Varying" I mean the headers that will change depending on the request. Access-Control-Allow-Origin is a prime example, as you do not want to expose all the whitelisted origins to all requests, only to those who actually come from a whitelisted origin.

To achieve this, we can use Next middleware.

CORS

This setup is copied directly from the official docs.

Quick rundown: check if the request origin is in our whitelist of origins. If it is, set Access-Control-Allow-Origin with the value of that origin in the response. If its not, don't add anything, effectively prohibiting cross-origin requests.

middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export const config = { ... };

const allowedOrigins = ['https://source.com'];

export function middleware(request: NextRequest) {
	const origin = request.headers.get('origin') ?? '';
	const isAllowedOrigin = allowedOrigins.includes(origin);

	const isPreflight = request.method === 'OPTIONS';
	if (isPreflight) {
		const preflightHeaders = {
			...(isAllowedOrigin && { 'Access-Control-Allow-Origin': origin })
		};
		return NextResponse.json({}, { headers: preflightHeaders });
	}

	const response = NextResponse.next({
		request: {
			headers: requestHeaders
		}
	});

	if (isAllowedOrigin) {
		response.headers.set('Access-Control-Allow-Origin', origin);
	}

	return response;
}

Content Security Policy

MDN: Content Security Policy For general understanding of CSP: https://scotthelme.co.uk/csp-cheat-sheet For the Next.js way of implementing it: Configuring: Content Security Policy

In a nutshell, Content Security Policy controls what, who, and where can

  1. Make requests
  2. Execute JS
  3. Change CSS

We can set it up like so:

middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export const config = { ... };
 
export function middleware(request: NextRequest) {
	const IS_DEV = process.env.NODE_ENV === 'development';
	const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
	const cspHeader = `
	    default-src 'self';
	    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
	    style-src 'self' 'nonce-${nonce}';
	    ...
	`
	// Replace newline characters and spaces
	const contentSecurityPolicyHeaderValue = cspHeader
	    .replace(/\s{2,}/g, ' ')
	    .trim()
	
	const requestHeaders = new Headers(request.headers)
	requestHeaders.set('x-nonce', nonce)
 
	requestHeaders.set(
	    'Content-Security-Policy',
	    contentSecurityPolicyHeaderValue
	)
 
	const response = NextResponse.next({
	    request: {
	      headers: requestHeaders,
	    },
	})
  
	response.headers.set(
	    'Content-Security-Policy',
	    contentSecurityPolicyHeaderValue
	)
 
	return response
}

You can notice we only add the policy in production, as it usually breaks stuff development.

You can then use

const nonce = (await headers()).get('x-nonce')

to read the nonce and use it in your components.

Trusted Types

MDN: Trusted Types

The Trusted Types API helps in preventing DOM-based XSS attacks. It allows you to lock down the creation of DOM elements that accept HTML, URLs, or JavaScript code, making sure only "trusted" sources can inject or modify the DOM. With Trusted Types, you can avoid dangerous code like:

div.innerHTML = userInput; // 🚨 Unsafe! Vulnerable to XSS.

The default way to enable trusted types in React is to use dompurify - npm. The problem is, it breaks when using Next.js SSR. Luckily, isomorphic-dompurify - npm solves this problem.

We can add write a policy in any script, and then import it in layout.tsx. Here is an example that allows json-ld structured data scripts.

lib/trustedTypes.ts
'use client';

import DOMPurify from 'isomorphic-dompurify';

window.trustedTypes?.createPolicy('default', {
	createHTML: (input: string) => {
		let PARSER_MEDIA_TYPE = undefined;
		if (input.startsWith('{"@context":"https://schema.org"')) {
			PARSER_MEDIA_TYPE = 'application/ld+json';
		}
		return DOMPurify.sanitize(input, {
			USE_PROFILES: { html: true },
			PARSER_MEDIA_TYPE: PARSER_MEDIA_TYPE,
			RETURN_TRUSTED_TYPE: false,
			FORCE_BODY: true,
			IN_PLACE: true,
			ADD_TAGS: ['script']
		});
	},
});

layout.tsx
...
import '@/lib/trustedTypes';
...

To enable trusted types, we have to add

"trusted-types default dompurify nextjs#bundler; require-trusted-types-for 'script';"

to the CSP policy. The trusted-types specifies which policies are allowed:

  • default: our created policy, and the default policy used by our page.
  • dompurify: policy created by dompurify
  • nextjs#bundler: policy by next.js bundler

HTTPS and HSTS

HTTPS ensures that all communication between your users and your website is encrypted and secure. But encryption alone is not enough; attackers can still attempt to downgrade your connections. That's where HSTS comes into play, by ensuring that browsers only communicate with your site using HTTPS, even if the user or attacker tries to use an unencrypted HTTP connection.

Setting up HSTS in Next.js

To enforce HSTS, you'll need to add the Strict-Transport-Security header. Here's an example configuration to include this header in your Next.js app.

  • max-age=31536000: This tells the browser to remember that this site uses HTTPS for the next year (31,536,000 seconds).
  • preload: This allows the domain to be preloaded into browsers that support HSTS preload lists. You can submit your domain to https://hstspreload.org/ to be added to the preload list.
  • includeSubDomains: This ensures that HSTS is enforced for all subdomains.
Subdomains follow domain!

Be careful when applying HSTS to subdomains. If you enable HSTS with includeSubDomains on a top-level domain, all subdomains must support HTTPS.

next.config.ts
import { NextConfig } from 'next';

const nextConfig: NextConfig = {
	async headers() {
		return [{
			source: '...',
			headers: [
				{
		            key: 'Strict-Transport-Security',
		            value: 'max-age=31536000; includeSubDomains; preload',
		        },
	        ]
		}];
	}
};

export default nextConfig;
All thoughts