Managing Environment Variables in Node.js with process.env + dotenv

These days, there has been a lot of talk about GitHub Copilot and its supposed risks of exposing API keys and API secrets. This is obviously a legitimate concern, as if someone gains access to these data, they could access services containing sensitive information or make requests on our behalf.

Aside from GitHub Copilot and the fact that you shouldn't be reading, let alone using information from private repositories (if this is proven, ouch, a serious mistake on your part), the reality is that GitHub is full of public repositories where people, in their distraction, forget and expose sensitive information.

The significant problem with generating commits containing sensitive information is that even though we can delete it due to the nature of Git and its version control, all the information is stored in the history. This isn't a problem while we're working locally since we could solve it with a rebase, but once we push changes to the history, it can bring serious issues to our project.

That's why it's better to completely avoid the problem and take the practice of never including API keys or secrets in our repositories. For this, we can use environment variables.

In Node.js, configuring environment variables is very simple. For that, we're going to use two tools: the global Node.js object process and a small yet powerful library called dotenv.

process, as mentioned before, is a global object that provides access to the current running Node.js process. What does this mean? When we start our application using the command node filename.js, an object is generated with information that we can immediately access from anywhere in our application.

The process object has many properties, one of which is env, and you can access it as process.env.

This property is an object containing environment information of the user running the process:

plain text code
plaintextCopy code
{
  USER: 'doomling',
  LOGNAME: 'doomling',
  HOME: '/Users/doomling',
  SHELL: '/bin/zsh',
  PWD: '/Users/doomling/repos/twitterator',
  ZSH: '/Users/doomling/.oh-my-zsh',
  TERM_PROGRAM: 'vscode',
  LANG: 'en_US.UTF-8',
  _: '/usr/local/bin/node'
}

The process object can be modified by assigning new properties, for example:

javascript code
javascriptCopy code
process.env.secret = "notasecret";
console.log(process.env);

This would result in:

javascript code
javascriptCopy code
{
  USER: 'doomling',
  LOGNAME: 'doomling',
  HOME: '/Users/doomling',
  SHELL: '/bin/zsh',
  PWD: '/Users/doomling/repos/twitterator',
  ZSH: '/Users/doomling/.oh-my-zsh',
  TERM_PROGRAM: 'vscode',
  LANG: 'en_US.UTF-8',
  _: '/usr/local/bin/node',
  secret: 'notasecret'
}

This is very useful for what we're going to do next, which is moving all the secret keys out of the code to consume them directly from the Node.js process.

Obviously, modifying the process from our .js file doesn't make sense because we'd still be exposing the keys we want to hide. This is where dotenv comes into play. This library allows us to consume a .config file where we can store all those variables that we don't want others to access and consume them directly from the process.env object.

To set it up, we first install the dependency:

plain text code
shellCopy code
npm install dotenv --save

And then we create a file in the root of our project named .env. In this file, we'll store all the variables we want to consume:

plain text code
shellCopy code
API_KEY=somevalue

Once we have our variables file created, we just need to require the dependency in the code and consume it. dotenv makes all the necessary changes to the process object through its config() method, consuming the .env file:

javascript code
javascriptCopy code
const dotenv = require('dotenv');
dotenv.config();

If we then perform another console.log(process.env), we should see:

javascript code
javascriptCopy code
{
  USER: 'doomling',
  LOGNAME: 'doomling',
  HOME: '/Users/doomling',
  SHELL: '/bin/zsh',
  PWD: '/Users/doomling/repos/twitterator',
  ZSH: '/Users/doomling/.oh-my-zsh',
  TERM_PROGRAM: 'vscode',
  LANG: 'en_US.UTF-8',
  _: '/usr/local/bin/node',
  API_KEY: 'somevalue'
}

Finally, in our code, we can directly consume the variables from the process object:

javascript code
javascriptCopy code
const { API_KEY } = process.env;
console.log(API_KEY); // somevalue

And that's it, you're done. Don't forget to add the .env file to your .gitignore to prevent it from ending up in the remote repository by mistake. It's a good practice to include a sample .env file, for example, .env.default, which contains the expected format for the project, always using placeholders or fake keys, of course.

It's also important to clarify in the README the instructions for each person cloning our repository to obtain the credentials needed to run the functionality locally.

How do we handle these variables in production?

You might be asking yourself this question, because if our repository doesn't have credential information, how will it work with deployment services or continuous integration? In that case, we'll need to take an extra step depending on where our application lives to provide it with information about which variables it should use when running or building.

If you're using instances or deployment services like DigitalOcean, Heroku, or Vercel, you can directly consult their documentation where you'll find detailed guides on how to create environment variables on each service.

I hope this blog helps in keeping more credentials secure. For any doubts, questions, or comments, don't hesitate to contact me.

Let's stay in touch: subscribe to "Sin Códigos," my bi-weekly newsletter. You can also follow me on social media to stay up to date with all my new content.

Sigamos en contacto: suscribite a Sin códigos, mi newsletter quincenal. También podés seguirme en redes para estar al tanto de todo mi nuevo contenido