NextJS HTTP response headers work differently than you (maybe) expect
Markus BrücknerWorking 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.):
- Implement static CSP headers, that are the same for each and every request.
- 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.