CWE-798: Use of Hard-coded Credentials - JavaScript/Node.js
Overview
Hard-coded credentials in JavaScript/Node.js applications occur when sensitive values (passwords, API keys, database credentials, JWT secrets, encryption keys) are embedded directly in source code or configuration files. This is especially dangerous in Node.js where code is often deployed to cloud platforms, containers, or serverless environments, and .env files are frequently committed to version control by mistake.
Primary Defence: Use cloud or enterprise secrets managers for production credentials. Use process.env as a deployment-time injection mechanism, and use dotenv only for local development with .env in .gitignore. An environment variable is a reasonable way to inject a secret at process start and a poor place to store one - see CWE-526.
Rotate first, then refactor. A credential that has been committed is
compromised regardless of whether the repository is public: it is in git log,
in every clone, and in every published npm tarball or container image built
since. Deleting the literal from HEAD changes none of that. Revoke the value
at the system that issued it - Stripe key, database user, JWT signing secret -
before or alongside the code change.
Common Vulnerable Patterns
Hard-coded Database Credentials
const mongoose = require('mongoose');
// VULNERABLE - Database password in code
const mongoUri = 'mongodb://admin:P@ssw0rd123@localhost:27017/myapp';
mongoose.connect(mongoUri);
// Password visible to all developers, in version control, and backups
Why this is vulnerable: Credential exposed in source code. Anyone with repository access can extract password.
Hard-coded API Keys in Express
const express = require('express');
const stripe = require('stripe')('sk_live_4eC39HqLyjWDarjtT1zdp7dc'); // VULNERABLE
const app = express();
app.post('/charge', async (req, res) => {
try {
const charge = await stripe.charges.create({
amount: req.body.amount,
currency: 'usd',
source: req.body.token
});
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Why this is vulnerable: Stripe secret key hard-coded. Anyone with code access can make charges.
JWT Secret in Code
const jwt = require('jsonwebtoken');
// VULNERABLE - JWT secret hard-coded
const JWT_SECRET = 'my-super-secret-key-12345';
function generateToken(userId) {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '24h' });
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
}
Why this is vulnerable: Anyone with the secret can sign a token for any user ID, so verifying the signature proves nothing.
AWS Credentials in Config File
// config/aws.js - VULNERABLE
const AWS = require('aws-sdk');
AWS.config.update({
accessKeyId: 'AKIAIOSFODNN7EXAMPLE', // VULNERABLE
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', // VULNERABLE
region: 'us-east-1'
});
const s3 = new AWS.S3();
module.exports = { s3 };
Why this is vulnerable: AWS credentials in code. Anyone with the code can call AWS with whatever access that IAM user's policies allow.
Two problems, and fixing one does not fix the other. This example uses
aws-sdk - v2 - because that is what code with hardcoded keys usually looks
like. npm marks it deprecated: "The AWS SDK for JavaScript (v2) has reached
end-of-support, and no longer receives updates." The secure examples below use
the v3 @aws-sdk/client-* packages, so the pair differs by SDK generation as
well as by credential handling. Migrating to v3 is worth doing on its own
account and changes nothing about this finding - v3 will read a hardcoded key
just as happily. What closes the finding is letting the SDK resolve credentials
from the environment, an instance role, or a secrets manager, which both
generations do by default when you do not pass them.
.env File Committed to Git
# .env file (VULNERABLE if committed to git)
DATABASE_URL=postgres://admin:MyP@ssw0rd@db.example.com:5432/production
STRIPE_SECRET_KEY=sk_live_51H...
JWT_SECRET=super-secret-jwt-key
SENDGRID_API_KEY=SG.abc123...
// app.js - this code is correct; the flaw is committing the .env file above
require('dotenv').config();
const dbUrl = process.env.DATABASE_URL; // Reads from .env
// If .env committed to git, all secrets exposed in repository history
Why this is vulnerable: .env file in version control persists forever in git history, even after deletion.
Hard-coded Encryption Keys
const crypto = require('crypto');
// VULNERABLE - Encryption key hard-coded
const ENCRYPTION_KEY = 'abcdef1234567890abcdef1234567890'; // 32 bytes
const IV_LENGTH = 16;
function encrypt(text) {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}
Why this is vulnerable: All encrypted data can be decrypted if attacker gets source code.
SMTP Credentials in Code
const nodemailer = require('nodemailer');
// VULNERABLE - Email credentials hard-coded
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: 'myapp@gmail.com',
pass: 'MyEmailPassword123' // VULNERABLE
}
});
async function sendEmail(to, subject, text) {
await transporter.sendMail({
from: 'myapp@gmail.com',
to,
subject,
text
});
}
Why this is vulnerable: SMTP password exposed. Attacker can send spam, phishing emails from account.
OAuth Client Secret in Frontend
// React/Vue frontend code - VULNERABLE
const OAUTH_CONFIG = {
clientId: 'abc123xyz',
clientSecret: 'secret_abc123xyz789', // VULNERABLE IN CLIENT-SIDE CODE
redirectUri: 'https://myapp.com/callback'
};
function initiateOAuth() {
// OAuth flow with exposed client secret
window.location.href = `https://oauth.provider.com/authorize?client_id=${OAUTH_CONFIG.clientId}&client_secret=${OAUTH_CONFIG.clientSecret}`;
}
Why this is vulnerable: Client secret visible in browser source code. Anyone can impersonate application.
Secrets in package.json Scripts
{
"name": "myapp",
"version": "1.0.0",
"scripts": {
"start": "DATABASE_PASSWORD=MyP@ss node app.js"
}
}
Why this is vulnerable: The value reaches the process as an ordinary
environment variable, so the application works and the shape survives review.
What it leaves behind is a credential literal in a committed file - in git log,
in every clone, and in every published npm tarball, exactly as above. A
password\s*=\s*['"] rule scanning JavaScript does not reach it either, because
the assignment sits inside a JSON string. The fix is to take the assignment out
of the script and let the process inherit the variable from the environment, as
in the dotenv pattern under Secure Patterns.
Built-in Admin Account and Support Token
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.urlencoded({ extended: false }));
// VULNERABLE - the credential the product *accepts* is built into the product
const ADMIN_USER = 'admin';
const ADMIN_PASS = 'ChangeMe123!';
// VULNERABLE - a fixed digest is still a fixed credential
const SUPPORT_TOKEN_SHA256 =
'5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8';
app.post('/login', async (req, res) => {
const { username, password } = req.body;
if (username === ADMIN_USER && password === ADMIN_PASS) {
return grantAdminSession(res); // works on every installation
}
// VULNERABLE - maintenance backdoor reachable over the network
const digest = crypto.createHash('sha256').update(password).digest('hex');
if (digest === SUPPORT_TOKEN_SHA256) {
return grantAdminSession(res);
}
return checkDatabase(username, password, res);
});
Why this is vulnerable: This is the inbound half of CWE-798, and it is a
different weakness from every pattern above. ADMIN_PASS is not a secret the
application needs to hold - it is an authenticator it should never have
accepted, and it is identical on every installation. Moving it into Key Vault or
process.env changes nothing: whoever has a copy of the product has a working
login for every deployment of it. The SUPPORT_TOKEN_SHA256 branch is the same
weakness with an extra step - a fixed digest means a fixed password, recoverable
from any rainbow table (this one is password), and reachable from anywhere the
login route is. Note that neither of these is an assignment a secret scanner's
password\s*=\s*['"] pattern finds; they are equality tests, so you have to
search authentication, activation and diagnostic routes for comparisons against
literals.
Secure Patterns
Credentials the Product Accepts: Authenticate, Do Not Compare
Take this one first. It is the fix for Built-in Admin Account and Support
Token above, and none of the secret-management patterns below address it. There
is no correct place to keep a password that is the same on every installation,
so the fix is to delete the credential rather than relocate it, and to
authenticate against a store that only exists once the customer has enrolled
somebody.
// SECURE - no credential literal is left anywhere to compare against
const express = require('express');
const argon2 = require('argon2');
const crypto = require('crypto');
// Per-installation store. Nothing in it ships with the product.
const {
countAdmins, createAdmin, findUserByName, deploymentApiKey
} = require('./credential-store');
const { grantSession } = require('./session');
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1
};
// A real Argon2id hash, at the same parameters, of a random value nobody knows.
// It exists only so the unknown-username path costs what a real verification
// costs - it is not a credential, and nothing authenticates against it.
const DUMMY_HASH =
'$argon2id$v=19$m=19456,p=1,t=2$CD3z/Xfc7GzbaNhKlXk3rg$SIK9R7LsLSd2YKkRCUm0ltvECHkIQbCS6ZOvPULB3N0';
const app = express();
app.use(express.urlencoded({ extended: false }));
// First-run enrolment gate. Until an administrator has been enrolled the
// product serves the setup route and refuses everything else, so a fresh
// install has no working credential rather than a well-known one.
app.use(async (req, res, next) => {
if (req.path.startsWith('/setup')) return next();
if (await countAdmins() === 0) {
return res.status(503).json({ error: 'Setup incomplete - enrol an administrator first' });
}
next();
});
app.post('/setup/enrol-admin', async (req, res) => {
if (await countAdmins() > 0) {
return res.status(409).json({ error: 'Administrator already enrolled' });
}
// req.body is undefined when no body was sent, so default it rather than
// letting an unauthenticated request destructure undefined into a 500.
const { username, password } = req.body ?? {};
await createAdmin(username, await argon2.hash(password, ARGON2_OPTIONS));
res.status(201).json({ enrolled: username });
});
app.post('/login', async (req, res) => {
const { username, password } = req.body ?? {};
const user = await findUserByName(username);
// argon2.verify() returns false for a wrong password rather than throwing,
// and the ?? keeps the unknown-username case on the same code path at the
// same cost.
const ok = await argon2.verify(user?.passwordHash ?? DUMMY_HASH, password ?? '');
if (!ok || !user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
grantSession(res, user);
res.json({ ok: true });
});
// Where the product genuinely must accept a fixed token - one generated at
// install time and stored per deployment, never one written into the source -
// compare it in constant time.
function tokenMatches(presented, expected) {
// crypto.timingSafeEqual throws RangeError "Input buffers must have the
// same byte length" when the lengths differ, so comparing the raw tokens
// turns a length mismatch into an exception - both an unhandled error path
// and a length oracle. Digesting first makes every comparison 32 bytes
// against 32 bytes.
const a = crypto.createHash('sha256').update(String(presented ?? '')).digest();
const b = crypto.createHash('sha256').update(expected).digest();
return crypto.timingSafeEqual(a, b);
}
app.post('/api/ingest', async (req, res) => {
if (!tokenMatches(req.get('x-api-key'), await deploymentApiKey())) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({ accepted: true });
});
Why this works: there is no module-level credential left to compare against,
so there is nothing an attacker can read out of the source, the npm tarball or
the container image and use against every other deployment.
argon2.verify() checks a per-user hash rather than testing equality against a
literal, so compromising one installation yields nothing about any other - and
because it is a password-hashing function rather than a raw digest, a stolen
hash is not a rainbow-table lookup the way SUPPORT_TOKEN_SHA256 was. The
DUMMY_HASH verification on the unknown-username path keeps both outcomes at
comparable cost, which is CWE-208 territory; every
login now pays the full hashing cost, so rate-limit the route. The enrolment
middleware is what stops the default coming back: a build that refuses to serve
anything outside /setup until an administrator exists cannot ship with a
working admin/ChangeMe123!, where a default that merely logs a warning
survives. For the fixed token, hashing both sides before timingSafeEqual is
not about strengthening the token - it is what lets one comparison handle every
input length, since timingSafeEqual on differing lengths raises
RangeError: Input buffers must have the same byte length and a try/catch
around it answers "wrong length" faster than it answers "wrong value". Measured
on Node 24.3: a presented key of x against a 17-character deployment key
returns false through the same path as a full-length mismatch, and the raw
call on those two buffers throws.
Rolling the fix out matters as much as making it. Every installation already running the vulnerable build has the old credential in its binary and probably in its manual, so removing it in the next release protects nobody who has not upgraded. Treat those instances as compromised: force enrolment on upgrade, invalidate sessions minted through the old path, and check access logs for logins as the built-in account.
Environment Variables with dotenv for local development
# .env file (NOT committed to git, in .gitignore)
DATABASE_URL=mongodb://admin:SecurePass@localhost:27017/myapp
STRIPE_SECRET_KEY=sk_live_actual_key_here
JWT_SECRET=randomly_generated_secret_key
Commit a .env.example alongside it listing every variable the application
reads, with the values left blank or set to obvious placeholders. Because the
real .env is git-ignored, it is otherwise the only record of which variables
exist, and a new environment fails at runtime with a missing-credential error
rather than at startup with a clear one:
// app.js
require('dotenv').config();
const mongoose = require('mongoose');
// SECURE - Credentials from environment
const mongoUri = process.env.DATABASE_URL;
if (!mongoUri) {
throw new Error('DATABASE_URL environment variable not set');
}
mongoose.connect(mongoUri);
Why this works: The .env file keeps local development credentials outside source code, and the .gitignore entry keeps the file itself out of version control. dotenv loads the values at runtime, and the check on DATABASE_URL throws at startup rather than letting the process run until the first connection fails. For production, inject equivalent values from a managed secret source and protect them from process dumps, platform metadata, and logs.
AWS SDK with IAM Roles (No Hard-coded Keys)
// SECURE - No credentials in code
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
// AWS SDK automatically uses IAM role credentials when running on:
// - EC2 instances with IAM instance profile
// - ECS tasks with IAM task role
// - Lambda functions with execution role
// - Local development: AWS CLI credentials (~/.aws/credentials)
const s3 = new S3Client({
region: process.env.AWS_REGION || 'us-east-1'
});
// No accessKeyId or secretAccessKey needed!
async function uploadFile(bucket, key, body) {
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body
}));
}
module.exports = { uploadFile };
Why this works: AWS SDK for JavaScript v3 uses the default credential provider chain, including IAM roles from the execution environment (EC2 instance profile, ECS task role, Lambda execution role) and local developer credentials. No access keys are embedded in code; production credentials should be temporary credentials provided by the platform, and IAM policies control what they can reach.
AWS Secrets Manager Integration
const {
SecretsManagerClient,
GetSecretValueCommand
} = require('@aws-sdk/client-secrets-manager');
const secretsManager = new SecretsManagerClient({
region: process.env.AWS_REGION || 'us-east-1'
});
async function getSecret(secretName) {
try {
const data = await secretsManager.send(new GetSecretValueCommand({
SecretId: secretName
}));
if (data.SecretString !== undefined) {
return JSON.parse(data.SecretString);
}
// Binary secret: SDK v3 already returns a Uint8Array, so wrap it and
// return the bytes. Decoding to a string here would corrupt a key.
return Buffer.from(data.SecretBinary);
} catch (error) {
console.error('Error retrieving secret:', error);
throw error;
}
}
// Usage
async function initDatabase() {
const dbCredentials = await getSecret('prod/database/credentials');
const mongoose = require('mongoose');
// Percent-encode the parts that came from the secret. See the note below.
const user = encodeURIComponent(dbCredentials.username);
const pass = encodeURIComponent(dbCredentials.password);
await mongoose.connect(
`mongodb://${user}:${pass}@${dbCredentials.host}/${dbCredentials.database}`
);
}
initDatabase();
Why this works: AWS Secrets Manager provides centralized secret storage with encryption at rest (KMS), rotation support, and fine-grained IAM access control. Secrets are retrieved at runtime via SDK credentials from the default provider chain, preferably IAM roles or workload identity in production. The JSON structure allows storing multiple related credentials together. Secrets Manager integrates with CloudTrail for audit logging and supports secret versioning for gradual rotation. The application keeps secrets out of source code and deployment artifacts.
SecretString is a JSON document, not the secret, which is what the
JSON.parse above is for - a secret stored as plain text throws there instead,
so parse only what you store as JSON. The binary branch returns bytes rather
than a string on purpose: SecretBinary is already a Uint8Array in SDK v3, so
a 'base64' argument to Buffer.from is inert, and toString('ascii') on the
result rewrites every byte above 0x7f - an AES key comes back the right length
and wrong, and nothing raises.
Interpolating a fetched credential into a connection URI is where this fix
breaks. A hard-coded password was one somebody typed; a generated one contains
whatever the generator emitted. Measured on the current MongoDB driver,
p@ss/w0rd interpolated raw parses to the password p with the rest read as
host and path - the connection is attempted against the wrong server with the
wrong credential, and nothing raises - while a:b@c throws
MongoParseError: Password contains unescaped characters.
encodeURIComponent on the username and password makes all of them round-trip.
The same applies to any postgres://, amqp:// or redis:// URL built the
same way.
Azure Key Vault Integration
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');
// SECURE - Uses managed identity or Azure CLI credentials
const vaultUrl = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;
const credential = new DefaultAzureCredential();
const client = new SecretClient(vaultUrl, credential);
async function getSecret(secretName) {
try {
const secret = await client.getSecret(secretName);
return secret.value;
} catch (error) {
console.error('Error retrieving secret:', error);
throw error;
}
}
// Usage
async function initStripe() {
const stripeKey = await getSecret('stripe-secret-key');
const stripe = require('stripe')(stripeKey);
return stripe;
}
module.exports = { initStripe };
Why this works: Azure Key Vault centralizes secret management with managed access control and audit logging. DefaultAzureCredential uses Managed Identity in Azure environments (App Service, Functions, VMs, AKS) or developer credentials locally, so credentials are not embedded in code. Secrets are encrypted at rest and in transit. Key Vault supports secret versioning, soft-delete protection, and RBAC for fine-grained access control. Credentials are retrieved at runtime rather than stored in the application artifact.
Google Cloud Secret Manager
const { SecretManagerServiceClient } = require('@google-cloud/secret-manager');
// SECURE - Uses application default credentials (ADC)
// Automatically works in GCP (service account) and local dev (gcloud auth)
const client = new SecretManagerServiceClient();
async function getSecret(projectId, secretName, version = 'latest') {
const name = `projects/${projectId}/secrets/${secretName}/versions/${version}`;
try {
const [response] = await client.accessSecretVersion({ name });
const payload = response.payload.data.toString('utf8');
return payload;
} catch (error) {
console.error('Error accessing secret:', error);
throw error;
}
}
// Usage
async function initApp() {
const projectId = process.env.GCP_PROJECT_ID;
const jwtSecret = await getSecret(projectId, 'jwt-secret');
const dbPassword = await getSecret(projectId, 'database-password');
// Use credentials...
}
initApp();
Why this works: Google Cloud Secret Manager stores credentials encrypted with customer-managed or Google-managed keys and exposes them only over authenticated API calls. Application Default Credentials pull short-lived tokens from the runtime (GCE, GKE, Cloud Run, or local gcloud auth), so you never hardcode keys in source. Access is gated by IAM roles, and every secret access is logged to Cloud Audit Logs for traceability. Because secrets are fetched at runtime, rotation is centralized - update the version in Secret Manager and deploy with the same code. Versioning lets you stage new values and roll back if needed. The code fetches only the specific secret versions it needs, minimizing blast radius.
HashiCorp Vault Integration
const vault = require('node-vault');
// SECURE - Vault client with token from environment
const vaultClient = vault({
apiVersion: 'v1',
endpoint: process.env.VAULT_ADDR,
token: process.env.VAULT_TOKEN // From CI/CD or local dev
});
// KV v2 nests the secret under data.data. node-vault's read() returns the
// whole API response body, so the payload is two levels down, not one.
async function getKvV2Secret(path) {
try {
const result = await vaultClient.read(path);
const secret = result.data && result.data.data;
if (!secret) {
throw new Error(`No KV v2 payload at ${path} - is this mount KV v1?`);
}
return secret;
} catch (error) {
console.error('Error reading from Vault:', error);
throw error;
}
}
// Usage
async function getDatabaseCredentials() {
const secrets = await getKvV2Secret('secret/data/database');
return {
host: secrets.host,
username: secrets.username,
password: secrets.password
};
}
module.exports = { getDatabaseCredentials };
Why this works: HashiCorp Vault keeps secrets off disk and out of source control, enforcing access through tokens delivered by your platform (CI/CD, Kubernetes auth, AppRole) instead of hardcoded keys. The Vault client talks over TLS, and Vault audit logs every request so you can trace who accessed what. Centralizing secrets means rotation is a Vault operation - no code change required - and leases can expire automatically for dynamic secrets. Keeping the token in the environment (or injected at runtime) prevents it from entering git history, and the helper function reads only the intended path, reducing accidental exposure of other paths.
Get the envelope right or the read succeeds and returns nothing. A KV v2
mount answers secret/data/database with
{ data: { data: {...}, metadata: {...} } }, and node-vault's read() hands
back that whole body. Returning result.data therefore yields
{ data, metadata }, so secrets.password is undefined - no exception, no
log line, and the failure surfaces later as a rejected database login rather
than as a failed secret read. The data.data unwrap and the explicit throw are
what turn a wrong mount or a KV v1 path into an error you can act on. A KV v1
mount has no data wrapper and no /data/ path segment, so the two are not
interchangeable.
Kubernetes Secrets
# The mount path is whatever the Pod spec says. Kubernetes has no default
# location for your Secrets - /var/run/secrets/kubernetes.io/serviceaccount
# is the ServiceAccount token, and nothing else lands there automatically.
spec:
volumes:
- name: db-credentials
secret:
secretName: db-credentials
containers:
- name: app
volumeMounts:
- name: db-credentials
mountPath: /etc/secrets/db
readOnly: true
// SECURE - Read secrets mounted as files in Kubernetes
const fs = require('fs').promises;
const path = require('path');
// mountPath from the Pod spec above
const SECRET_DIR = process.env.SECRET_DIR || '/etc/secrets/db';
async function getSecret(secretName) {
const secretPath = path.join(SECRET_DIR, secretName);
try {
const secret = await fs.readFile(secretPath, 'utf8');
return secret.trim();
} catch (error) {
console.error(`Error reading secret ${secretName}:`, error);
throw error;
}
}
// Usage
async function initDatabase() {
const dbPassword = await getSecret('password');
const dbHost = process.env.DATABASE_HOST;
const mongoose = require('mongoose');
// encodeURIComponent: the password now comes from a generator, and an
// unescaped @ or / silently truncates it and rewrites the host.
await mongoose.connect(
`mongodb://admin:${encodeURIComponent(dbPassword)}@${dbHost}/myapp`
);
}
initDatabase();
Why this works: Kubernetes Secrets are mounted into the pod filesystem at runtime, so images and source never contain credentials. Access to the Secret object is governed by namespace scoping and RBAC, reducing who can read it. Kubernetes Secret values are base64-encoded and are not encrypted in etcd by default, so production clusters should enable encryption at rest and restrict RBAC tightly. Mounted Secret updates can be reflected in projected volumes, but the application must reread or reload the value to use a rotated secret. This pattern avoids baking secrets into images, but it is still a cluster secret-management control rather than a full external vault.
Each key in the Secret becomes a file named for that key under the mountPath,
which is why the code reads password and not database-password: the Secret's
name is in the Pod spec, not in the filename. Reading from a hard-coded
/var/run/secrets/<name> finds nothing, because that tree belongs to the
ServiceAccount token projection.
JWT Secret from Environment with Validation
const jwt = require('jsonwebtoken');
// SECURE - JWT secret from environment, validated
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRY = process.env.JWT_EXPIRY || '24h';
// Validate on startup
if (!JWT_SECRET || JWT_SECRET.length < 32) {
throw new Error('JWT_SECRET must be set and at least 32 characters');
}
function generateToken(userId) {
return jwt.sign(
{ userId },
JWT_SECRET,
{
expiresIn: JWT_EXPIRY,
issuer: 'myapp',
audience: 'myapp-users'
}
);
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET, {
issuer: 'myapp',
audience: 'myapp-users'
});
} catch (error) {
return null;
}
}
module.exports = { generateToken, verifyToken };
Why this works: Loading the JWT secret from runtime configuration keeps it out of source control and container images, and the startup guard enforces minimum length so weak keys fail fast instead of deploying insecure defaults. Validation happens before the app serves requests, preventing tokens signed with short or missing secrets. Use a secret store or platform secret mechanism for the value, and rotate with a dual-secret or key-id strategy when existing tokens must remain valid. The code also keeps the secret out of logs and restricts issuer/audience claims, reducing misuse.
Key Security Functions
Startup Validation for Required Secrets
function validateRequiredEnvVars() {
const required = [
'DATABASE_URL',
'JWT_SECRET',
'STRIPE_SECRET_KEY',
'AWS_REGION'
];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error('Missing required environment variables:', missing);
process.exit(1);
}
// Validate secret strength
if (process.env.JWT_SECRET.length < 32) {
console.error('JWT_SECRET must be at least 32 characters');
process.exit(1);
}
}
// Call at application startup
validateRequiredEnvVars();
Secrets Caching with TTL
class SecretsCache {
constructor(ttl = 3600000) { // 1 hour default
this.cache = new Map();
this.ttl = ttl;
}
async getSecret(key, fetchFn) {
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.value;
}
// Fetch fresh secret
const value = await fetchFn(key);
this.cache.set(key, {
value,
timestamp: Date.now()
});
return value;
}
clear() {
this.cache.clear();
}
}
// Usage
const secretsCache = new SecretsCache();
async function getDbPassword() {
return secretsCache.getSecret('db-password', async (key) => {
// Fetch from AWS Secrets Manager, Azure Key Vault, etc.
return await fetchFromSecretsManager(key);
});
}
Redact Secrets from Logs
const winston = require('winston');
const SECRET_KEY = /pass|secret|token|key|credential|authorization/i;
function redactSecrets(value, seen = new WeakSet()) {
if (value === null || typeof value !== 'object') return value;
// Return a marker, not the value. Returning `value` hands back the
// unredacted original for anything reached twice - a cycle, or just the
// same config object referenced from two properties.
if (seen.has(value)) return '[Circular]';
seen.add(value);
if (Array.isArray(value)) return value.map(v => redactSecrets(v, seen));
const out = {};
for (const [k, v] of Object.entries(value)) {
out[k] = SECRET_KEY.test(k) ? '[REDACTED]' : redactSecrets(v, seen);
}
return out;
}
// Mutate info in place. Returning a fresh object drops the Symbol keys winston
// uses internally for level and message, and the transport then writes nothing
// at all - a redactor that silences the log looks identical to one that works.
const redactFormat = winston.format((info) => {
for (const k of Object.keys(info)) {
if (k === 'level' || k === 'message') continue;
info[k] = SECRET_KEY.test(k) ? '[REDACTED]' : redactSecrets(info[k]);
}
return info;
});
const logger = winston.createLogger({
format: winston.format.combine(redactFormat(), winston.format.json()),
transports: [new winston.transports.File({ filename: 'app.log' })]
});
// Redacted whether the secret arrives flat or nested
logger.info('User login', { username: 'user@example.com', password: 'secret123' });
logger.info('DB connect', { metadata: { db: { password: 'secret123' } } });
Redact the whole record, not one property of it. A format that only rewrites
info.metadata covers exactly one calling convention. logger.info(msg, meta)
merges meta into the top level of info, which is how most call sites are
written, so logger.info('User login', { password: 'x' }) goes to disk in
plaintext past a redactor that reads as correct - and so does anything nested
more than one level under metadata. Verified against winston by logging a
canary through each shape and grepping the output; the flat and deeply-nested
cases both leaked before this change and neither raised anything. Match on the
key name at every depth instead, and test the redactor by logging a known value
through each of the page's other logging call sites rather than only the one
next to the helper.
Rotate Secrets Gracefully
class SecretRotation {
constructor(primaryKey, secondaryKey) {
this.primary = primaryKey;
this.secondary = secondaryKey;
}
// Try primary, fallback to secondary during rotation
verify(token) {
try {
return jwt.verify(token, this.primary);
} catch (primaryError) {
try {
return jwt.verify(token, this.secondary);
} catch (secondaryError) {
return null;
}
}
}
sign(payload) {
// Always sign with primary
return jwt.sign(payload, this.primary);
}
}
// Usage
const secrets = new SecretRotation(
process.env.JWT_SECRET_PRIMARY,
process.env.JWT_SECRET_SECONDARY // Old key during rotation
);
module.exports = { secrets };
Analysis Steps
Locate the Hard-coded Credential
// Line 15 in src/config/database.js
const mongoUri = 'mongodb://admin:MyP@ssw0rd123@localhost:27017/myapp';
Identify Credential Type
- Database password (MongoDB)
- Other types: API keys, JWT secrets, encryption keys, AWS credentials, OAuth secrets
Assess Exposure
- Is file in version control? (Check
git log src/config/database.js) - Is credential valid for production? (Critical if yes)
- How many developers have access?
- Is repository public or has been public?
Determine Scope
- Search for other hard-coded credentials:
grep -r "password\s*=\s*['\"]" . - Check git history:
git log -p -S "MyP@ssw0rd123" - Scan for patterns: Use TruffleHog or Gitleaks
Verification
Two questions a re-scan cannot answer, and they are the two that matter:
- The application starts with the variable set and refuses to start without
it. Run it with
DATABASE_URLunset and expect the startup error, then set it and expect a successful connection. A fix that readsprocess.envand falls back to the old literal passes the first test and not the second. - The old credential no longer authenticates. Present the value from
git logto the system that issued it and expect a rejection. This is the only check that distinguishes "removed from the file" from "revoked", and it is the one the scanner is silent about.
The rest is what the tooling is for: git status to confirm .env is ignored,
gitleaks detect --log-opts=--all over the history rather than the working
tree, and a re-scan to confirm the reported finding is closed.
Common Pitfalls
- Moving a database URI from a JavaScript string literal into a
.envfile, then committing that.envfile because it was created before.gitignorewas updated to exclude it -dotenvcorrectly loads it at runtime either way, so the fix looks complete from the application's perspective while the secret remains in git history. - Setting the real production
STRIPE_SECRET_KEYorJWT_SECRETdirectly in a DockerfileENVinstruction or a checked-indocker-compose.ymlinstead of injecting it at deploy time -process.envreads it correctly at runtime, but the value is still committed in plaintext in the image build file. - Fetching a secret from AWS Secrets Manager or Vault once during a serverless function's cold start and holding it in a module-level variable indefinitely - in long-lived containers this defeats rotation, since the cached value is never refreshed until the process restarts.
- Redacting secrets from application logs (
redactSecrets()) while still allowingconsole.log(process.env)or a debug/health-check endpoint that dumps the full environment - a single unredacted debug path exposes everything the redaction helper was meant to protect.
Additional Resources
- AWS SDK for JavaScript v3 Secrets Manager
- Azure Key Vault SDK
- dotenv Documentation
- GitHub Secret Scanning
- Gitleaks
- Google Cloud Secret Manager
- MongoDB Connection String URI Format - including the percent-encoding rules for username and password
- OWASP Secrets Management Cheat Sheet
- TruffleHog