Back to Developer's Study Materials

How to Securely Store API Keys in Node.js (The Right Way with process.env)

Stop hardcoding secrets — the complete guide to API key security in Node.js and Next.js (2026)

Definition: What API Key Security Means

An API key is a secret credential that authenticates your application to a third-party service — Stripe, OpenAI, Twilio, Google Maps, AWS, and thousands of others. API key security means ensuring that these credentials are never hardcoded in source files, never committed to version control, and never logged to standard output where they can be scraped.

The three cardinal rules of API key security:

Never hardcode

No keys in .js, .ts, .py, or any source file

Never commit

No keys in git history — ever

Never log

No keys in console.log or error messages

Key Point: Secrets belong in environment variables — not in code. The application reads secrets from the environment at runtime, keeping them completely out of your repository.

What: The Attack Surface — Where Keys Leak

API keys do not only leak through obvious mistakes. Understand every surface where secrets can escape your control:

Git History (most common)

Even if you delete a secret from a file and commit again, the original value remains in git history permanently. git log -p will reveal it. GitHub, GitLab, and Bitbucket store the full history, including secrets from commits that happened years ago. Bots run by threat actors scan every public repo continuously.

# A bot runs this on your repo within 60 seconds of your push:
git log --all -p | grep -E "(sk-|AIza|AKIA|stripe_secret|_KEY=)"
# Your key is now in their database.

Application Logs

Logging frameworks, request loggers, and error trackers (Sentry, Datadog) can capture environment variables or request headers that include Bearer tokens. Even console.log(process.env) will dump ALL environment variables including secrets to stdout.

// DANGEROUS — logs entire environment including secrets
console.log('Config:', process.env);

// ALSO DANGEROUS — request headers may include Authorization: Bearer sk-...
app.use(morgan('combined')); // logs all headers

Client-Side JavaScript Bundles (Next.js NEXT_PUBLIC_)

In Next.js, any variable prefixed NEXT_PUBLIC_ is statically inlined into the JavaScript bundle served to browsers. Anyone can open DevTools and find it. This is the most underestimated leak vector for Next.js developers.

Docker Image Layers

If you copy a .env file into a Docker image layer, the secrets persist even if you delete the file in a later layer. Docker images pushed to public registries (Docker Hub) expose every layer — including deleted files — to anyone who pulls the image.

# DANGEROUS Dockerfile — secrets baked into layer 2
COPY .env .
RUN npm run build
RUN rm .env  # Too late! .env is still in layer 2

Warning: The attack surface is larger than most developers assume. Git history, logs, client bundles, and Docker layers are all places where secrets have been found in the wild. Treat all of them as potential leak points.

When: Every Time You Use These Secret Types

The secure storage rules apply to every credential that grants access to a paid service, a database, or private data:

Payment API keys

Stripe secret key (sk_live_...)

AI model API keys

OpenAI (sk-proj-...), Anthropic (sk-ant-...)

Database connection strings

postgresql://user:pass@host/db

OAuth client secrets

Google, GitHub, Discord client secrets

JWT signing secrets

JWT_SECRET used to sign tokens

Cloud credentials

AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY

SMS / Email API keys

Twilio auth token, SendGrid API key

Webhook secrets

Stripe webhook signing secret

Rule of thumb: If rotating the value would break your application, it is a secret that must be stored in an environment variable — not in code.

How To: 6 Methods to Secure API Keys in Node.js

1Local Development: .env File + dotenv + .gitignore

Three files work together to load secrets locally without committing them. This is the foundation of all Node.js secret management.

Step 1 — Install dotenv:

npm install dotenv

Step 2 — Create .env (never commit this file):

# .env — local secrets only, never commit
STRIPE_SECRET_KEY=sk-test-YOUR_KEY
OPENAI_API_KEY=sk-proj-abc123...
DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/myapp
JWT_SECRET=super-secret-jwt-signing-key-at-least-32-chars
WEBHOOK_SECRET=whsec_abc123...

Step 3 — Add .env to .gitignore:

# .gitignore
.env
.env.local
.env.*.local
.env.production

# DO commit this — it documents required variables without values
# .env.example is safe to commit

Step 4 — Create .env.example (commit this — no real values):

# .env.example — safe to commit, documents required variables
STRIPE_SECRET_KEY=sk-test-YOUR_KEY_HERE
OPENAI_API_KEY=sk-proj-your_key_here
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=at-least-32-characters-random-string
WEBHOOK_SECRET=whsec_your_webhook_secret

Step 5 — Load dotenv at the very start of your application:

// server.js or app.js — first line in the file
require('dotenv').config(); // Must be before any other imports that use process.env

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { OpenAI } = require('openai');

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY, // reads from process.env
});

const app = express();
// ...

For Next.js: Next.js auto-loads .env.local without needing dotenv. Create .env.local and add it to .gitignore. Variables without NEXT_PUBLIC_ prefix are server-only.

2Validate Required Secrets at Startup

Fail fast at startup if a required secret is missing. This prevents cryptic runtime errors hours later when a feature tries to use the missing key.

// config/secrets.js — validate and export secrets
require('dotenv').config();

function requireEnv(name) {
  const value = process.env[name];
  if (!value) {
    throw new Error(
      `Missing required environment variable: ${name}\n` +
      `Copy .env.example to .env and fill in the values.`
    );
  }
  return value;
}

module.exports = {
  stripeSecretKey: requireEnv('STRIPE_SECRET_KEY'),
  openaiApiKey: requireEnv('OPENAI_API_KEY'),
  databaseUrl: requireEnv('DATABASE_URL'),
  jwtSecret: requireEnv('JWT_SECRET'),
  // Optional with default
  port: process.env.PORT || '3000',
  nodeEnv: process.env.NODE_ENV || 'development',
};

Then import from this module instead of accessing process.env directly throughout your codebase:

// routes/payments.js
const { stripeSecretKey } = require('../config/secrets');
const stripe = require('stripe')(stripeSecretKey);

// If STRIPE_SECRET_KEY is missing, the app crashes at startup
// with a clear error message — not when the first payment is attempted

For TypeScript and Next.js, use Zod for type-safe validation:

// lib/env.ts
import { z } from 'zod';

const envSchema = z.object({
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  OPENAI_API_KEY: z.string().startsWith('sk-'),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
});

// This throws at build time / server start if validation fails
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
  throw new Error('Invalid environment variables — see above for details');
}

export const env = parsed.data;

// Usage:
// import { env } from '@/lib/env';
// const stripe = new Stripe(env.STRIPE_SECRET_KEY);

3The NEXT_PUBLIC_ Trap — Bad vs Good Pattern

This is the most common mistake Next.js developers make. The NEXT_PUBLIC_ prefix instructs Next.js to embed the variable value into the client-side JavaScript bundle at build time. It becomes visible in DevTools to every visitor.

WRONG — Key exposed to browser
# .env.local — DANGEROUS
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_abc123
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-abc123
NEXT_PUBLIC_DATABASE_URL=postgresql://...

// pages/index.tsx — DANGEROUS
// This key ships in the JavaScript bundle!
const stripe = new Stripe(
  process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!
);
CORRECT — Key stays server-side
# .env.local — SAFE
STRIPE_SECRET_KEY=sk_live_abc123
OPENAI_API_KEY=sk-proj-abc123
DATABASE_URL=postgresql://...

# Only publishable/public keys use NEXT_PUBLIC_
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_abc123

// app/api/charge/route.ts — server-side only
export async function POST(req: Request) {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
  // Key never reaches the browser
}

Easy check: Open your production site, open DevTools, go to Sources, and search for your API key prefix (sk_live, sk-proj). If you find it in a .js bundle, your NEXT_PUBLIC_ prefix is leaking a secret. Revoke the key immediately.

The table below clarifies what belongs where:

VariablePrefix needed?Visible in browser?Example
Secret API keyNo prefixNo (server only)STRIPE_SECRET_KEY
Publishable / public keyNEXT_PUBLIC_Yes (intentional)NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
Analytics site IDNEXT_PUBLIC_Yes (intentional)NEXT_PUBLIC_GA_MEASUREMENT_ID
Database URLNo prefixNo (server only)DATABASE_URL

4Production: Platform Env Vars and Secrets Managers

In production, never deploy a .env file. Instead, use platform-level environment variable management:

Vercel

Project Settings > Environment Variables. Supports per-environment values (Production / Preview / Development). Variables are encrypted at rest and injected at build time and runtime.

# Via Vercel CLI
vercel env add STRIPE_SECRET_KEY production
vercel env add OPENAI_API_KEY production

# Or pull all remote env vars to local .env.local (for debugging):
vercel env pull .env.local

Railway / Render / Fly.io

Each platform has a Variables / Environment tab in the project dashboard. Set variables there — they are injected as environment variables into your running container.

# Railway CLI
railway variables set STRIPE_SECRET_KEY=sk_live_abc123
railway variables set DATABASE_URL=postgresql://...

# Render — set via dashboard or render.yaml
envVarGroups:
  - name: production-secrets
    envVars:
      - key: STRIPE_SECRET_KEY
        sync: false  # marks it as a secret in the UI

AWS Secrets Manager (enterprise-grade)

For production applications that need audit logging, automatic rotation, and fine-grained IAM access control. Fetch secrets at runtime using the AWS SDK.

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: 'us-east-1' });

async function getSecret(secretName: string): Promise<string> {
  const command = new GetSecretValueCommand({ SecretId: secretName });
  const response = await client.send(command);
  return response.SecretString!;
}

// At startup — fetch secrets once, cache them
const stripeKey = await getSecret('prod/myapp/stripe-secret-key');
const stripe = new Stripe(stripeKey);

HashiCorp Vault (self-hosted / enterprise)

For teams that need on-premise secret management with dynamic secrets, lease-based access, and full audit trails.

import vault from 'node-vault';

const client = vault({
  endpoint: process.env.VAULT_ADDR,
  token: process.env.VAULT_TOKEN,
});

const { data } = await client.read('secret/data/myapp/production');
const stripeKey = data.data.STRIPE_SECRET_KEY;

5Rotating Keys Without Downtime (Dual-Key Approach)

Key rotation is the process of replacing an old API key with a new one. The naive approach causes downtime — you deploy the new key, the old one stops working instantly. The dual-key approach eliminates downtime:

// Phase 1: Add the NEW key alongside the OLD key in environment variables
// .env / platform env vars:
// STRIPE_SECRET_KEY=sk_live_OLD_KEY
// STRIPE_SECRET_KEY_NEW=sk_live_NEW_KEY

// In your code, support both during transition:
function getStripeClient() {
  // Try new key first, fall back to old key
  const key = process.env.STRIPE_SECRET_KEY_NEW || process.env.STRIPE_SECRET_KEY;
  return new Stripe(key!);
}

// Phase 2: Deploy. Both keys are valid. Verify new key works in production.

// Phase 3: Remove STRIPE_SECRET_KEY_OLD from env vars.
// Set STRIPE_SECRET_KEY=sk_live_NEW_KEY
// Remove STRIPE_SECRET_KEY_NEW

// Phase 4: Deploy again. Rotation complete, zero downtime.

For webhook secrets, Stripe supports multiple endpoints simultaneously. During rotation:

// Support multiple webhook secrets during rotation
const webhookSecrets = [
  process.env.STRIPE_WEBHOOK_SECRET_NEW,
  process.env.STRIPE_WEBHOOK_SECRET_OLD,
].filter(Boolean) as string[];

function verifyWebhook(payload: Buffer, signature: string) {
  for (const secret of webhookSecrets) {
    try {
      return stripe.webhooks.constructEvent(payload, signature, secret);
    } catch {
      // Try next secret
    }
  }
  throw new Error('Webhook signature verification failed');
}

6Scanning for Leaked Keys: git-secrets, TruffleHog, GitHub Secret Scanning

Add automated scanning to catch leaked secrets before they reach remote repositories:

# Install git-secrets (prevents committing secrets)
brew install git-secrets  # macOS
git secrets --install     # installs pre-commit hook
git secrets --register-aws  # adds AWS key patterns

# Add custom patterns for your keys:
git secrets --add 'sk_live_[a-zA-Z0-9]+'        # Stripe live keys
git secrets --add 'sk-proj-[a-zA-Z0-9]+'        # OpenAI keys
git secrets --add 'sk-ant-[a-zA-Z0-9-]+'        # Anthropic keys

# Now any commit containing these patterns will be blocked
# TruffleHog — scan git history for secrets that already leaked
pip install trufflehog
trufflehog git file://. --only-verified

# Or scan a remote repo:
trufflehog github --repo=https://github.com/yourorg/yourrepo

# GitHub Secret Scanning
# Enabled automatically for public repos
# Enable for private repos: Settings > Code security > Secret scanning
# GitHub will alert you when a known secret pattern is detected in pushes

Add a pre-commit hook using Husky to run secret scanning on every commit:

# Install Husky
npm install --save-dev husky
npx husky init

# .husky/pre-commit
#!/bin/sh
# Run git-secrets check before every commit
git secrets --scan || exit 1

# Also useful: detect-secrets (Python)
pip install detect-secrets
detect-secrets scan > .secrets.baseline
# Add to pre-commit: detect-secrets-hook --baseline .secrets.baseline

Complete Checklist: (1) Install dotenv and create .env, (2) Add .env to .gitignore immediately, (3) Create .env.example with no real values, (4) Validate required secrets at startup, (5) Never use NEXT_PUBLIC_ for secret keys, (6) Use platform env vars in production, (7) Install git-secrets as a pre-commit hook.

Why: Real API Key Leak Consequences

API key leaks are not theoretical. Here are documented patterns of what happens after a key is exposed:

OpenAI Key Leak

A leaked OpenAI API key is immediately scraped by bots that continuously monitor GitHub. The key is then used to submit thousands of API requests (GPT-4 completions, DALL-E image generation) at your expense. Developers have reported waking up to $1,000–$10,000 in unexpected OpenAI charges after a single leaked key. OpenAI offers limited refunds for provable leaks.

Time from leak to first abuse: typically under 60 seconds on public GitHub

Stripe Secret Key Leak

A leaked Stripe secret key (sk_live_) allows an attacker to create PaymentIntents, issue refunds to their own bank account, view all customer data (names, emails, last 4 digits), and create new products. This is both a financial loss and a GDPR/PCI-DSS breach that must be reported to regulators.

AWS Access Key Leak

Leaked AWS credentials can spin up hundreds of GPU instances for cryptocurrency mining within minutes. AWS bills can reach $50,000–$100,000 before detection. AWS will often work with affected customers but does not guarantee a refund. The attacker may also exfiltrate data from S3 buckets, RDS databases, or other services.

Emergency response if you leak a key: (1) Immediately revoke the exposed key in the provider's dashboard, (2) Generate a new key and deploy it, (3) Check your provider's usage dashboard for unauthorized charges, (4) Review access logs for what was accessed, (5) File a support ticket with the provider explaining the leak, (6) If it was in git history, use git filter-repo --invert-paths --path-glob='*.env' and force-push (coordinate with your team).

Frequently Asked Questions

How do I store API keys securely in Node.js?

Use a .env file with the dotenv package for local development. Create a .env file with your secrets, add .env to .gitignore, and load it with require('dotenv').config() at the very start of your application. Access secrets via process.env.YOUR_KEY_NAME. In production, set environment variables through your hosting platform's dashboard — never deploy a .env file.

Is it safe to put API keys in .env files?

Yes, for local development, IF you add .env to .gitignore immediately. The .env file must never be committed to version control. Note that .env files are plain text — anyone with access to your filesystem can read them. For production environments, use your hosting platform's encrypted environment variable storage (Vercel dashboard, Railway variables, AWS Secrets Manager) instead of .env files.

What happens if I commit my API keys to GitHub?

Automated bots scan GitHub for leaked secrets within seconds of a push. Your key will likely be abused within minutes. Deleting the key from your code and committing again does NOT help — it remains in git history. You must: (1) Immediately revoke the key in the provider dashboard, (2) Generate a new key and redeploy, (3) Check for unauthorized usage, (4) Optionally use git filter-repo to purge history, (5) Force push. Assume the key is compromised the moment it appears in any commit.

How do I store API keys in production (Vercel, AWS)?

On Vercel: Project Settings > Environment Variables. Add keys per-environment (Production/Preview/Development). On AWS: use AWS Secrets Manager for sensitive secrets, or Parameter Store for less sensitive configuration. Fetch secrets at runtime with the AWS SDK. On Railway or Render: use the Variables tab in your project dashboard. On any platform: never ship a .env file — always use the platform's native secret management.

What is the NEXT_PUBLIC_ security risk for API keys?

Variables prefixed with NEXT_PUBLIC_ are statically embedded into the browser JavaScript bundle at build time. Every visitor to your site can see them by opening DevTools > Sources. Only use NEXT_PUBLIC_ for genuinely public values (Stripe publishable key, Google Analytics measurement ID). All secret keys (Stripe secret key, OpenAI key, database passwords, JWT secrets) must have NO prefix and must only be accessed in server-side code (API routes, Server Components, getServerSideProps).

Related Guides & Tools

Continue learning about Node.js security and configuration:

Share this article with Your Friends, Collegue and Team mates

Stay Updated

Get the latest tool updates, new features, and developer tips delivered to your inbox.

Occasional useful updates only. Unsubscribe in one click — we never sell your email.

Feedback for How to Securely Store API Keys in Node.js Guide

Tell us what's working, what's broken, or what you wish we built next — it directly shapes our roadmap.

You make the difference

Good feedback is gold — a rough edge you hit today could be smoother for everyone tomorrow.

  • Feature ideas often jump the queue when lots of you ask.
  • Bug reports with steps get fixed faster — paste URLs or examples if you can.
  • Name and email are optional; we won't use them for anything except replying if needed.