Instrumenting Next.js with runtime secret injection

3 months ago 1

control panel Photo by EJ Strat

What's an Instrumentation File?

Instrumentation is a new feature introduced in Next.js 14 that allows you to run custom logic when your application starts. The instrumentation.ts/js file lives at the root of your Next.js project and exposes a register() API, which will be called once when a new Next.js server instance is initiated.

Instrumentation is most often used to initiate logging or telemetry services. This example from Vercel's docs shows a basic example of how this works:

In this example, the register() function initializes OpenTelemetry for the Next.js application.

Instrumenting with Secrets

As we've seen, the register() API is meant primarily for running code at startup and initializing services or tools that can be used later during application runtime. This lends itself nicely as a way to inject secrets into our app. We've discussed the benefits of runtime secret injection, specifically in the context of Next.js in a previous post, so have a look at that if you want to know more. The TL;DR is that it keeps secrets out of code, version control, and build artifacts. Runtime secret injection also makes your application more portable and easier to distribute, either within your team or for public consumption.

Why not just use a .env file?

Next.js evaluates (server-side) secrets and environment variables at runtime, if provided as a .env file. While this works, it comes with a number of drawbacks, security concerns, and clumsy DX. We've covered this topic in-depth in another post, but in short, .env files are problematic because they often end up in version control or left lying on local disks unencrypted, increasing the risk of a secret leak. They're nearly impossible to manage securely at scale, are difficult to distribute across a team, and offer no access control or security. Secret management tools offer encryption, access controls, easy collaboration, auditing, and rotation, making them a much safer, scalable, and developer friendly solution.

So let's take a look at how we could use the instrumentation file to inject secrets from a secrets management tool into our Next.js application at runtime.

The Setup

We're going to bootstrap a fresh Next.js project, add an instrumentation file to it, and use a secrets management service to inject secrets into our app at runtime.

I've bootstrapped a Next.js application using npx create-next-app@latest and the following options:

In this example, we'll be using Phase to inject secrets into our app, but this approach can be adapted to work with any secret management service, such as AWS Secrets Manager, HashiCorp Vault, or even a custom solution.

For this demo, I've created an app on Phase called instrumentation-demo with some dummy secrets:

secrets in phase

Step 1: Create the Instrumentation File

Create a new file called instrumentation.ts at the root of your Next.js project. This file will contain the logic to fetch and inject secrets into your application. If your project uses a different folder structure, such as a src directory, you can place the file there. Check out the Next.js instrumentation documentation for more details.

In our file, we will export a function called register() that will be called when the Next.js server starts. We'll leverage this function to fetch secrets from Phase and inject them into the global scope, making them accessible throughout our application. For now, let's just add a console.log() to make sure it's being called correctly when our app starts up:

Start Next.js with npm run dev and we can verify that our instrumentation file is working:

Note: Instrumentation only runs when the Next server starts, so if you're using next dev, you will need to stop and restart the server to see changes in your instrumentation file.

Step 2: Fetching secrets

Now let's fetch some secrets inside our register() function. Since we're using Phase, we could either use the Node SDK or the REST API. We'll use the API here, which should make it easier to adapt this solution if you're using a different secrets management platform.

Since we're awaiting fetch(), we'll make our register() function async, and define the URL, params, and headers for our API call. I'm referencing my Phase API Token from an environment variable called PHASE_API_TOKEN, which in this case is stored in a .env file, but could come from any source, such as a CI/CD environment variable or a secrets management service.

Then we'll do a simple fetch() to get secrets from Phase, and log them to the console.

Start up our app again with npm run dev, and we can verify that secrets are being fetched and logged to the console:

Step 3: Access secrets in our app

Now we're fetching secrets when our app starts up, but we need to make them accessible to application code. There are a few different ways to do this. For this demo, we'll use a global object which we'll access as globalThis.

First, let's set an empty secrets Record in global. Next, we'll update our code to loop through the response data, and populate globalThis.secrets with the key/value pairs from our API response:

Stop and re-start the app with npm run dev, and we should see our secret keys, this time from global.secrets

Now, we can access these secrets via globalThis anywhere in our app. I've added the following in my app/page.tsx to read and display these secrets:

Opening the app in my browser at http://localhost:3000 shows my secret keys and values rendered on the page:

server rendered secrets

Global secrets? Really?

If you're anything like me, seeing secrets in global has been making you uncomfortable as well. While these global secrets are only available within the Node.js runtime of our Next.js server (an improvement over standard environment variables by the way, which are readable by any process on the host machine), you may still want to expose these secrets in a more controlled way. There are various alternatives to this approach, but a full exploration of these is beyond the scope of this post and will depend a lot on the specifics of your application configuration. A couple of ideas to get you started are either writing the secrets to a temporary file on disk, or creating a shared module-local cache to hold your secrets:

In the example above, you can call setRuntimeEnv() in your instrumentation file to set the secrets, and use getRuntimeEnv() to access them in your application code. This way, you avoid polluting the global scope with secrets, while still making them accessible throughout your app.

What about client-side environment variables?

This is a contentious and complicated issue, and even Vercel doesn't seem to have a standard solution to fit all use-cases. As we've discussed in a previous post, Next.js inlines NEXT_PUBLIC_ environment variables at build-time, which means they are not evaluated at runtime and can't be changed without rebuilding your app. There are a number of workarounds and hacks to get around this limitation, but they all come with trade-offs.

In my opinion, the best approach is to avoid using environment variables with the NEXT_PUBLIC_ prefix altogether, and instead pass these variables as props from server to client components. Set up your client components to accept the required values as props:

And then pass the value from your server component to the client component when rendering it:

This approach ensures that your client-side components receive the necessary values without relying on build-time environment variables, and you also get the added benefit of deliberately passing only the values that are needed, rather than exposing all environment variables to the client.

If you have a large number of client-side variables, or a complicated component tree, you could also consider using a context provider to pass these variables down the tree instead of prop drilling.

client component secrets

Conclusion

In this post, we've explored how to use the instrumentation file in Next.js 14 to inject secrets into our application at runtime. By leveraging the register() function, we can fetch secrets from a secrets management service like Phase and make them available throughout our app.

This approach keeps secrets out of code, version control, and build artifacts, making our application more secure and portable.

If you're using a different secrets management service, you can adapt the code to use it's API or SDK. The key takeaway is that the instrumentation file provides a powerful way to run custom logic at application startup, making it an ideal place for runtime secret injection.

Further Reading

If you're interested in learning more about Next.js instrumentation, secrets management, or runtime secret injection, here are some resources to check out:

Read Entire Article