How to use environment variables (.env) safely
Every junior developer commits an API key to GitHub once. The lucky ones realize within an hour. The unlucky ones get an email at 3 AM from AWS asking why their account just spent $4,000 on crypto mining. This guide is the simple, complete version of how environment variables actually work, the .env workflow most teams use, and what to do the moment you realize you leaked something.
What environment variables are
An environment variable is a key-value pair the operating system holds in memory for a running process. Your code reads them via:
// Node.js
const dbUrl = process.env.DATABASE_URL;
# Python
import os
db_url = os.getenv("DATABASE_URL")
// Go
dbUrl := os.Getenv("DATABASE_URL")
Why use them? Three real reasons:
- Secrets stay out of your code. The DB password isn't in your repo, so it can't leak through GitHub.
- Same code runs in different environments. Your laptop hits the dev DB, staging hits staging, production hits production. Code unchanged.
- Operations can change config without deploying. Need to update a feature flag? Change the env var, restart, done. No code release.
What is a .env file
Operating systems make setting environment variables locally annoying (every shell does it differently, vars don't persist between sessions, etc.). The community workaround is the .env file: a plain-text file at the root of your project containing KEY=VALUE lines. A library like dotenv reads the file at app startup and loads the values into the process environment.
# .env (do not commit this file)
DATABASE_URL=postgresql://app:secret@localhost:5432/myapp
JWT_SECRET=4j6Qm2vXf8sR1pK7yT9wB3uN5aE0xH
STRIPE_SECRET_KEY=sk_test_4eC39HqLyjWDarjtT1zdp7dc
NODE_ENV=development
PORT=3000
This is a regular file. No quotes needed unless your value has spaces. Comments start with #. Values are read as strings; cast them yourself if you need numbers.
The basic setup, language by language
Node.js
npm install dotenv
# or for newer Node versions, no library needed:
node --env-file=.env app.js
If using the dotenv library, load it at the very top of your entry file:
// app.js (top line)
require('dotenv').config();
const port = process.env.PORT || 3000;
Python
pip install python-dotenv
# app.py
from dotenv import load_dotenv
load_dotenv()
import os
db_url = os.getenv("DATABASE_URL")
Other languages
Go, Rust, Ruby, PHP, and the rest all have similar libraries. Or you can avoid the library entirely by exporting variables in your shell before running:
export DATABASE_URL=postgresql://...
export JWT_SECRET=4j6Qm2v...
go run main.go
The non-negotiable rules
1. Add .env to your .gitignore
Before you write a single env var, make sure your .gitignore has these lines:
# Local environment files
.env
.env.local
.env.*.local
*.env
Run git status after creating .env. If git shows it as a new file, your gitignore is wrong. Fix it before doing anything else.
2. Commit a .env.example, not .env
Create a .env.example with the same keys but placeholder values:
# .env.example (commit this)
DATABASE_URL=postgresql://user:password@host:port/dbname
JWT_SECRET=generate-a-32-byte-random-string-here
STRIPE_SECRET_KEY=sk_test_...
NODE_ENV=development
PORT=3000
This is the template that tells the next developer (or future you) what variables they need to set. Commit it. Update it whenever you add a new variable. New developers copy it: cp .env.example .env, then fill in real values.
3. Never read secrets in client-side code
Anything in browser JavaScript is visible to anyone with DevTools. Frontend "env vars" (like Vite's VITE_* or Next.js's NEXT_PUBLIC_*) are baked into the bundle at build time and shipped to every user. They are NOT secret.
Rule of thumb: if it has the word "secret" in the variable name (STRIPE_SECRET_KEY, JWT_SECRET, DB_PASSWORD), it should never be exposed to the browser. Use it server-side only.
4. Different environments, different files
You'll have .env (local dev), .env.test (CI tests), and in production, the actual env vars are set by the host (Heroku, Vercel, AWS, etc.) via their dashboard, not from a file. Never put production secrets in any file you store on disk locally.
5. Validate at startup
If a required env var is missing, your app should crash loudly at startup, not silently produce buggy behavior in production three weeks later.
// Node, top of app.js
const required = ['DATABASE_URL', 'JWT_SECRET', 'STRIPE_SECRET_KEY'];
for (const key of required) {
if (!process.env[key]) {
console.error(`Missing required env var: ${key}`);
process.exit(1);
}
}
Five lines of defensive code that save hours of debugging.
Practice secrets-handling on real broken code
InternQuest's Security track has 37+ missions including hardcoded-secret bugs in Express middleware, Flask routes, and React components. The kind of code where you have to spot the leak and fix it. Free virtual SWE internship simulator.
Try a security mission →What to do the moment you realize you leaked a secret
You pushed .env to GitHub. Or you committed an API key inline because you "wanted to test something quickly." Or your .gitignore wasn't right and you didn't notice. Either way, a secret is now public. Maybe for 30 seconds, maybe for two days.
Assume it's compromised. Automated bots scan public GitHub commits for known secret patterns within minutes. AWS keys, Stripe keys, GitHub tokens, and OpenAI keys all have public regexes that scrapers run constantly. The window between "I pushed it" and "someone is using it" can be under five minutes.
The recovery sequence:
- Rotate the secret immediately. Go to the provider (AWS, Stripe, OpenAI, Google, etc.), revoke the leaked key, generate a new one. This is the only step that actually fixes the problem. Everything else is hygiene.
- Update .env locally and in production. Replace the leaked value with the new one in your local
.env, your hosting provider's env vars dashboard, and your CI secrets. - Tell your team. Especially if the leaked secret was shared (DB password, internal API key). Other people may need to update their local copies.
- Don't bother rewriting git history. The secret is in scraper databases by now. Removing the commit doesn't help and can break others' clones. The rotation is what matters.
- Add a pre-commit hook to prevent the same mistake.
git-secretsorgitleaksscan your staged files for known secret patterns before commit. Five minutes to set up. - Check provider logs to see if the leaked key was used by anyone unauthorized. If yes, escalate to your security team if you have one.
Doing all of this within 30 minutes of noticing a leak is what separates "we caught and rotated it, no harm done" from "we're explaining a $40k cloud bill to the CFO."
Common .env mistakes to avoid
Quoting the values
# Wrong (the quotes become part of the string)
DB_URL="postgresql://user:pass@localhost/db"
# Right
DB_URL=postgresql://user:pass@localhost/db
Some dotenv implementations strip quotes, some don't. Don't quote unless your value contains a space.
Spaces around the equals sign
# Wrong
PORT = 3000
# Right
PORT=3000
Logging the entire env
// Don't do this
console.log('Loaded env:', process.env);
Logs go to log aggregators, sometimes get sent to third parties (Datadog, Sentry, etc.). Never dump the env. Log specific non-secret keys if you need debugging.
Using one .env across teammates
Slack-ing a .env file to a new teammate is fine for trivial dev secrets. But for shared services (databases that contain real test data, paid API keys), each developer should have their own credentials so you can revoke individual ones without locking everyone out.
Hardcoding fallbacks
// Bad
const secret = process.env.JWT_SECRET || 'dev-secret-12345';
// Good
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET is required');
The fallback feels helpful and is dangerous. If JWT_SECRET ever gets unset in production by accident, your app will silently use the placeholder and tokens issued with that secret are now spoofable.
Beyond .env: secret managers
For real production work, .env is the entry-level. The next step up is a secret manager:
- 1Password / Bitwarden / Doppler for team-shared dev secrets.
- AWS Secrets Manager / Google Secret Manager / HashiCorp Vault for production secrets that rotate automatically.
- Cloud provider native: every platform (Vercel, Netlify, Heroku, Render, Fly.io) has a built-in secret store accessible to your app at runtime.
You won't be expected to set these up as an intern, but you'll likely use one. The pattern is the same: your code reads process.env.KEY, the platform makes sure the value is there.
The 60-second checklist before every push
Habituate this:
- Run
git status. Is .env (or any .env.*) in the changed files? Stop, fix gitignore. - Run
git diff --cached. Scan for any string that looks like a secret (long random characters, sk_ prefixes, tokens). If you see one, abort. - Search your diff for "password", "secret", "key", "token". One out of ten times you'll catch a stray hardcoded value before it lands.
- Push.
Sixty seconds. Saves the entire next-day disaster.
Drill the muscle of spotting hardcoded secrets
InternQuest's debug missions include real codebases with hardcoded secrets, missing env-var usage, and silent fallbacks to defaults. Find them, fix them, see what real PR review looks like. Free virtual SWE internship simulator.
Try a mission →