NextJS HTTP response headers work differently than you (maybe) expect

Markus Brückner

Working with NextJS is usually a rather straightforward experience, but there is the odd thing here and there that drives me up the wall. One of them was the attempt to implement Content-Security-Policy headers for a bunch of frontend apps the other day. Should be rather straightforward, NextJS even has documentation on how to do this. However, this documentation assumes that you want to do one of two things (well, actually 4 things, because there are two different routers in NextJS, which necessarily follow different approaches towards HTTP headers. We'll focus on the Pages Router here for now.):

  1. Implement static CSP headers, that are the same for each and every request.
  2. Implement Nonces in the CSP header, making them – by definition – different for each request.

I did not want to go the Nonce route, so static headers it should be. Easily enough: implement a headers() function in your NextJS config object and return the headers as needed. So my implementation looked roughly like this:

headers() {
if (process.env.CSP_ENABLED) {
return [
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: `
default-src 'self' https://some-external-service;
connect-src 'self'
${process.env.API_GATEWAY_URL};
`
.replace(/\n/g, '');
}
]
]
}
}

The actual implementation is more complex than that, but you get the gist. The function supports disabling the headers based on some configuration variable. This allows me to deploy the same container image to different environments (e.g. DEV and PROD) and have it return the correct headers.

Tested it locally, CSP_ENABLED=true, I see headers, CSP_ENABLED=false, the headers are gone, everything works as expected. Push to upstream, CI pipeline deploys to DEV aaaaand.... they're gone. OK, maybe some weird setup issue with the load balancer in the DEV cluster? One check directly against the container later: nope, they're really gone. Same goes when running the application in production mode locally.

The more NextJS config-aware will already be screaming at their screens: "You &#)&$! That's expected behavior!". Well, it wasn't for me. Reading the docs I'd gathered, that the headers() function gets called to get the headers for every request. It doesn't. It gets called once at build time. Hence why my headers were gone, because I obviously don't have CSP_ENABLED set in the CI environment. It worked locally in DEV mode, because every time the application gets restarted, it is basically rebuilding itself, so any changes in the environment during my tests did the expected thing.

The solution

The solution to the problem: go down the middleware road as documented for Nonce-based CSP headers. The documentation focuses solely on the Nonce use case (hence why I ignored it initially), but it's actually the way to go for any kind of dynamic decision about the returned headers. Even a rather simple one like deciding whether to return any or not.

My middleware now looks like this (again: slightly more complex in real life and the value isn't recreated on every request):

export function middleware(request: NextRequest) {
const response = NextResponse.next();
if (process.env.CSP_ENABLED) {
response.headers.set(
"Content-Security-Policy",
`default-src 'self' https://some-external-service;
connect-src 'self'
${process.env.API_GATEWAY_URL};`
.replace(/\n/g, ""),
);
}
return response;
}

Works like a charm. I can switch on and off my headers as needed.

There is a slight snag in the original implementation: in order to share the header configuration between different applications, my initial idea was to implement a JSON config containing the headers and have that mounted into the containers via Kubernetes. Would work perfectly from next.config.js, as this is running on NodeJS with full access to all the APIs and everything. It doesn't work, however, in a NextJS middleware, because those are executed in the Edge runtime, which basically only supports a subset of the Web APIs plus access to process.env. You don't even have access to the NextJS config. You probably could do weird workarounds like having an API route in your app, that does have full access and returns the header configuration or even rebuild the app on container restart to include the new headers, but the added complexity probably is not worth it. Since I do have access to process.env and most of the headers are static anyway (it's basically just a handful of URLs, that get configured through the environment), implementing the middleware in a shared library and using it in every one of the relevant applications felt like the much, much more straightforward way to approach things. So that's what I did in the end.