6.1 Secure Configuration Management

When it comes to configuration secrets in closed-source projects, like API keys or HTTPS session decryption keys, it is not uncommon for them to be hard-coded in place. In open-source projects, instead, these are typically instead obtained through environment variables or encrypted configuration files that aren’t committed to version control systems alongside our codebase.

In the case of open-source projects, this allows the developer to share the vast majority of their application without compromising the security of their production systems. While this might not be an immediate concern in closed-source environments, we need to consider that once a secret is committed to version control, it’s etched into our version history unless we force a rewrite of that history, scrubbing the secrets from existence. Even then, it cannot be guaranteed that a malicious actor has gained access to these secrets at some point before they were scrubbed from history, and thus a better solution to this problem is rotating the secrets that might be compromised, revoking access through the old secrets and starting to use new, uncompromised secrets.

While effective, this approach can be time-consuming when we have several secrets under our belt, and when our application is large enough, leaked secrets pose significant risk even when exposed for a short period of time. As such, it’s best to approach secrets with careful consideration by default, and avoid headaches later in the lifetime of a project.

The absolute least we could be doing is giving every secret a unique name, and placing them in a JSON file. Any sensitive information or configurable values may qualify as a secret, and this might range from private signing keys used to sign certificates to port numbers or database connection strings.

  1. {
  2. "PORT": 3000,
  3. "MONGO_URI": "mongodb://localhost/mjavascript",
  4. "SESSION_SECRET": "ditch-foot-husband-conqueror"
  5. }

Instead of hardcoding these variables wherever they’re used, or even placing them in a constant at the beginning of the module, we centralize all sensitive information in a single file that can then be excluded from version control. Besides helping us share the secrets across modules, making updates easier, this approach encourages us to isolate information that we previously wouldn’t have considered sensitive, like the work factor used for salting passwords.

Another benefit of going down this road is that, given we have all environment configuration in a central store, we can point our application to a different secret store depending on whether we’re provisioning the application for production, staging, or one of the local development environments used by our developers.

When it comes to sharing the secrets, given we’re purposely excluding them from source version control, we can take many approaches, such as using environment variables, storing them in JSON files kept in an Amazon S3 bucket, or using an encrypted repository dedicated to our application secrets.

Using what’s commonly referred to as "dot env" files is an effective way of securely managing secrets in Node.js applications, and there’s a module called nconf that can aid us in setting these up. These files typically contain two types of data: secrets that mustn’t be shared outside of execution environments, and configuration values that should be editable and which we don’t want to hardcode.

One concrete and effective way of accomplishing this in real-world environments is using several "dot env" files, each with a clearly defined purpose. In order of precedence:

  • .env.defaults.json can be used to define default values that aren’t necessarily overwritten across environments, such as the application listening port, the NODE_ENV variable, and configurable options you don’t want to hard-code into your application code. These default settings should be safe to check into source control

  • .env.production.json, .env.staging.json, and others can be used for environment-specific settings, such as the various production connection strings for databases, cookie encoding secrets, API keys, and so on

  • .env.json could be your local, machine-specific settings, useful for secrets or configuration changes that shouldn’t be shared with other team members

Furthermore, you could also accept simple modifications to environment settings through environment variables, such as when executing PORT=3000 node app, which is convenient during development.

We can use the nconf npm package to handle reading and merging all of these sources of application settings with ease.

The following piece of code shows how you could configure nconf to do what we’ve just described: we import the nconf package, and declare configuration sources from highest priority to lowest priority, while nconf will do the merging (higher priority settings will always take precedence). We then set the actual NODE_ENV environment variable, because libraries rely on this property to decide whether to instrument or optimize their output.

  1. // env
  2. import nconf from 'nconf'
  3. nconf.env()
  4. nconf.file('environment', `.env.${ nodeEnv() }.json`)
  5. nconf.file('machine', '.env.json')
  6. nconf.file('defaults', '.env.defaults.json')
  7. process.env.NODE_ENV = nodeEnv() // consistency
  8. function nodeEnv() {
  9. return accessor('NODE_ENV')
  10. }
  11. function accessor(key) {
  12. return nconf.get(key)
  13. }
  14. export default accessor

The module also exposes an interface through which we can consume these application settings by making a function call such as env('PORT'). Whenever we need to access one of the configuration settings, we can import env.js and ask for the computed value of the relevant setting, and nconf takes care of the bulk of figuring out which settings take precedence over what, and what the value should be for the current environment.

  1. import env from './env'
  2.  
  3. const port = env('PORT')

Assuming we have an .env.defaults.json that looks like the following, we could pass in the NODE_ENV flag when starting our staging, test, or production application and get the proper environment settings back, helping us simplify the process of loading up an environment.

  1. {
  2. "NODE_ENV": "development"
  3. }

We usually find ourselves in need to replicate this sort of logic in the client-side. Naturally, we can’t share server-side secrets in the client-side, as that’d leak our secrets to anyone snooping through our JavaScript files in the browser. Still, we might want to be able to access a few environment settings such as the NODE_ENV, our application’s domain or port, Google Analytics tracking ID, and similarly safe-to-advertise configuration details.

When it comes to the browser, we could use the exact same files and environment variables, but include a dedicated browser-specific object field, like so:

  1. {
  2. "NODE_ENV": "development",
  3. "BROWSER_ENV": {
  4. "MIXPANEL_API_KEY": "some-api-key",
  5. "GOOGLE_MAPS_API_KEY": "another-api-key"
  6. }
  7. }

Then, we could write a tiny script like the following to print all of those settings.

  1. // print-browser-env
  2. import env from './env'
  3. const browserEnv = env('BROWSER_ENV')
  4. const prettyJson = JSON.stringify(browserEnv, null, 2)
  5. console.log(prettyJson)

Naturally, we don’t want to mix server-side settings with browser settings, because browser settings are usually accessible to anyone with a user agent, the ability to visit our website, and basic programming skills, meaning we would do well not to bundle highly sensitive secrets with our client-side applications. To resolve the issue, we can have a build step that prints the settings for the appropriate environment to an .env.browser.json file, and then only use that file on the client-side.

We could incorporate this encapsulation into our build process, adding the following command-line call.

  1. node print-browser-env > browser/.env.browser.json

Note that in order for this pattern to work properly, we’ll need to know the environment we’re building for at the time when we compile the browser dot env file, as passing in a different NODE_ENV environment variable would produce different results depending on our target environment.

By compiling client-side configuration settings in this way, we avoid leaking server-side configuration secrets onto the client-side.

Furthermore, we should replicate the env file from the server-side in the client-side, so that application settings are consumed in much of the same way in both sides of the wire.

  1. // browser/env
  2. import env from './env.browser.json'
  3. export default function accessor(key) {
  4. if (typeof key !== 'string') {
  5. return env
  6. }
  7. return key in env ? env[key] : null
  8. }

There are many other ways of storing our application settings, each with their own associated pros and cons. The approach we just discussed, though, is relatively easy to implement and solid enough to get started. As an upgrade, you might want to look into using AWS Secrets Manager. That way, you’d have a single secret to take care of in team members' environments, instead of every single secret.

A secret service also takes care of encryption, secure storage, secret rotation (useful in the case of a data breach), among other advanced features.