PLAINFINCH learn by building

Keeping secrets out of code

You will be able to move secrets out of your code into a .env file and stop the server early if one is missing.

Why shouldn't secrets live in server.js?

The starting code signs basket cookies with cookieParser('change-me-in-production'). Whoever knows that string can forge a valid signature, so it is a secret. Written into server.js, it goes everywhere the code goes: into Git, into every commit forever, to every collaborator, into screenshots and into any AI tool you paste the file into.

Removing it later doesn't help much. Git keeps old versions, so a secret that was ever committed must be treated as leaked and replaced with a new one. Automated scanners search public repositories for keys within minutes of a push. The safe habit is to keep secrets out of code from the first line.

  • Session and cookie signing secrets
  • Database passwords and connection strings
  • API keys for payment, email or map services
  • Admin tokens and anything else that grants access

What are environment variables?

Every running program gets a set of named text values from whatever started it, called environment variables. In Node.js they appear in process.env. process.env.NODE_ENV and process.env.PORT are the usual examples. The code reads a name, and each machine supplies its own value: your laptop one secret, the live server another.

That separation is the point. The code is the same everywhere and safe to share. The values are set where the server runs: in a hosting platform's settings page, a process manager's config, or the shell. Values are always strings, so convert numbers with Number(process.env.OPEN_HOUR) and compare flags as text: process.env.NODE_ENV === 'production'.

How does a .env file work with dotenv?

Typing variables into the shell every time you start a server gets old quickly. A .env file holds them instead, one NAME=value per line, next to server.js. The dotenv package reads that file and copies each line into process.env. Call require('dotenv').config() as the very first line, before any other code reads process.env. Code that runs earlier sees undefined.

By default dotenv doesn't overwrite a variable that is already set. That is helpful: on the live server, the host sets the real values, there is no .env file, and the same config() call does nothing harmful. Node.js 22 can also load the file by itself with node --env-file=.env server.js, or process.loadEnvFile() in code. The practice server supports dotenv, so this lesson uses it.

# .env (never committed)
COOKIE_SECRET=dev-only-change-me-in-production

// server.js, first line
require('dotenv').config();
app.use(cookieParser(process.env.COOKIE_SECRET));
FigureThe .env file holds COOKIE_SECRET. require dotenv config copies it into process.env, and cookieParser reads it there. .gitignore lists .env, so Git commits server.js, .gitignore and .env.example but never .env.The .env file holds COOKIE_SECRET. require dotenv config copies it into process.env, and cookieParser reads it there. .gitignore lists .env, so Git commits server.js, .gitignore and .env.example but never .env.
The secret lives in .env and reaches the code only through process.env. Because .gitignore lists .env, the commit carries the code and .env.example, never the value.

How do you keep .env out of Git?

Add .env to .gitignore before the first commit. Git then never stages the file, and git status stops listing it. The lesson's .gitignore already has the line. Check it with git check-ignore .env on your own machine, which prints the name if the file is ignored.

Commit a .env.example instead, with every variable name and a harmless placeholder such as COOKIE_SECRET=change-me. A new collaborator copies it to .env and fills in their own values. The example documents what the server needs without giving anything away.

If a real secret ever does get committed, rotate it: make a new one, put it where the server reads it, and stop accepting the old one. For a cookie secret that logs everyone out, which is a small price.

What should happen when a secret is missing?

Stop at startup with a clear message. A server that starts without COOKIE_SECRET looks fine until the first visitor adds a loaf, and then fails with a confusing error deep inside a request. Worse, some code falls back to a default, like process.env.COOKIE_SECRET || 'secret', and runs in production with a secret that is printed in its own source.

A few lines near the top avoid both. Check each required variable, and throw an error that names it. The server refuses to start, the host's logs show exactly what is missing, and nobody is ever served by a half-configured server.

for (const name of ['COOKIE_SECRET', 'DATABASE_FILE']) {
  if (!process.env[name]) {
    throw new Error(`${name} is not set`);
  }
}

How should development and production values differ?

Use different secrets in every environment. Your laptop's .env can hold a throwaway value like dev-only-change-me-in-production. The live server gets a long random one, which you can generate with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))", and set in the host's settings. If a development value leaks, nothing real is at risk.

NODE_ENV tells the code which environment it is in. Hosts usually set it to production, and Express itself behaves a little differently then, for example hiding error details. Use it for behaviour such as secure cookies, never as a replacement for a separate secret.

How do secrets leak even when they aren't in the code?

Through responses. Anything the server sends, the visitor can read: JSON from a settings API, a value written into a <script> tag, an error page with a stack trace. Only public values belong in what the browser receives. Keys for payment or email services stay on the server, and the server calls those services on the browser's behalf.

Through logs. console.log(process.env) or console.log(req.headers) prints secrets and session cookies into files that are kept, copied and searched. Log the names of settings that loaded, never their values. The Log view here makes this easy to spot: if you can read a secret there, so can anyone with access to the real logs.

How can you check what loaded on this page?

Open the Data view and read the lesson's files. .env holds the development secret, and .gitignore lists it. Then press **Add a loaf** and open Headers and body in the Requests view. The basket cookie's value starts with s:, then the count, a dot and a signature. If the secret ever changes, every cookie signed with the old one stops verifying, req.signedCookies.basket becomes false, and the count starts over. That is why rotating a cookie secret empties baskets and ends sessions.

To confirm the secret arrived without printing it, log only whether it is set: console.log('COOKIE_SECRET loaded:', Boolean(process.env.COOKIE_SECRET)). The Log shows true or false, which is all you need to know.

What do AI tools get wrong about secrets?

AI tools often write keys straight into examples, add fallbacks like || 'mysecret', or forget .gitignore. They may also call require('dotenv').config() halfway down the file, after a module has already read process.env. Check the order and the defaults. Also look for secrets passed in URLs, such as ?key=..., which end up in browser history and server logs.

Never paste real secrets into an AI chat or a shared screenshot, and treat any key that ended up there as leaked. When an AI suggests putting an API key into front-end code so a page can call a service directly, stop: that key is now public. Route the call through your own server instead.

Field notes

  • Secrets written in your code end up in Git and in every copy of the project.
  • Environment variables reach a Node.js program through process.env.
  • A .env file holds them for your laptop, and .gitignore keeps it out of Git.
  • If a needed secret is missing, stop at startup with a clear message.

Easy to get wrong

A fallback means a misconfigured server runs with a secret everyone can read in the code. Failing at startup makes the mistake impossible to miss.

dotenv only fills process.env when config() runs. Code that reads a variable earlier gets undefined.

Every value in a response reaches the visitor's browser. Secret keys must stay on the server.

Questions people ask

Is a .env file safe on the live server?

It works, but most hosts offer a settings page or secret store for environment variables, which keeps the values out of the deployed files entirely. Use that where you can, and make sure a .env file on a server is readable only by the account that runs the app.

Can I put non-secret settings in .env too?

Yes. Port numbers, the site's name and feature switches fit well, because they differ between environments. Keep the file's job clear by listing every variable in .env.example.

What if I already pushed a secret to a shared repository?

Replace it at once: create a new secret, update the server, and revoke the old one with whichever service issued it. Rewriting Git history can remove the file, but copies may already exist, so rotation is the real fix.

Why does the practice server have a .env file I can see?

So you can practise loading one. In a real project, .env lives only on your machine and is never shared or committed. The values here are placeholders that protect nothing.

Keep going