Frameworks · 9 min read
Node.js .env security
Last updated
How .env files leak in Node.js projects
Node.js applications usually aren't served from a document root the way PHP applications are, but environment files still leak through a handful of recurring patterns:
- a static file middleware or dev server that serves the project directory,
- secrets bundled into client-side JavaScript through public variable prefixes,
- Docker images built with
COPY . .that include the file, - a reverse proxy that serves the project directory for static assets.
Static file serving
The classic mistake is pointing static middleware at the project root:
import express from "express";
const app = express();
app.use(express.static(".")); // serves package.json, source code, backups, ...Express's static middleware ignores dotfiles by default, so /.env itself usually returns 404. That is not a safety net you should rely on: everything else in the directory is served, including source code, configuration files and backups without a leading dot (for example env.backup). In Express 4, files inside dot-directories can be served as well. Serve a dedicated folder and deny dotfiles explicitly:
import express from "express";
import path from "node:path";
const app = express();
app.use(
express.static(path.join(import.meta.dirname, "public"), {
dotfiles: "deny", // 403 for any path segment starting with a dot
index: false,
}),
);The same applies to quick static servers: running npx serve . or python3 -m http.server in a project directory publishes its .env. If Nginx serves your static assets, add the dotfile rule from the Nginx guide.
Public variables in frontend bundles
Frontend build tools replace references to certain environment variables with their literal values at build time. Those values end up in JavaScript files that every visitor downloads:
| Tool | Variables embedded in client code |
|---|---|
| Next.js | NEXT_PUBLIC_* |
| Vite | VITE_* (configurable with envPrefix) |
| Create React App | REACT_APP_* |
| Expo | EXPO_PUBLIC_* |
A public prefix makes a value public
Custom webpack configurations are a related trap. Injecting the whole environment, for example with new DefinePlugin({ "process.env": JSON.stringify(process.env) }), copies every variable of the build machine into the bundle. Define individual keys instead.
In Next.js, variables without the prefix are only available on the server. Importing the server-only package in modules that read secrets makes the build fail if such a module is accidentally imported into a client component.
Docker images
COPY . . copies the .env file into the image unless it is excluded. Deleting it in a later step doesn't help, because every layer can be extracted from the image. Anyone who can pull the image can read the file.
.env
.env.*
!.env.example
.git
node_modulesPass configuration at runtime instead: docker run --env-file ./production.env, the env_file: key in Docker Compose, or orchestrator secrets.
Loading variables safely
Node.js can load an env file without dependencies:
node --env-file=.env server.js # Node.js 20.6+
node --env-file-if-exists=.env server.js # Node.js 22.9+: no error if the file is missingWith the dotenv package, load it as early as possible, for example with import "dotenv/config" at the top of the entry file. In both cases, validate required variables at startup so that a missing secret fails loudly instead of falling back to an unsafe default:
import { z } from "zod";
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
SESSION_SECRET: z.string().min(32),
});
// Throws at startup with the names of missing or invalid variables.
export const env = schema.parse(process.env);Never log the entire process.env object. It ends up in log files, error trackers and support tickets.
Production without a .env file
In production, the environment file is often unnecessary. Hosting platforms, container orchestrators and systemd can inject variables directly:
[Service]
User=example-app
WorkingDirectory=/srv/example-app
# Readable only by root; systemd reads it before dropping privileges.
EnvironmentFile=/etc/example-app/production.env
ExecStart=/usr/bin/node server.jsKeep such files outside any directory that a web server or static middleware can reach, and restrict them with chmod 600. See the general protection checklist for the remaining layers.
Remediation checklist
- Static middleware serves a dedicated
publicfolder withdotfiles: "deny" - No secret uses a
NEXT_PUBLIC_,VITE_,REACT_APP_orEXPO_PUBLIC_prefix - The build doesn't inject the whole
process.envinto client code .dockerignoreand.gitignoreexclude.envfiles- Required variables are validated at startup, and the environment is never logged
- Production variables come from the platform or a root-only file outside the application directory
- The IsMyENVPublic check reports no exposure