Skip to content

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
# .gitignore
.env
.env.local
.env.*.local

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:

# .env.example - committed, contains no real values
DATABASE_URL=
STRIPE_SECRET_KEY=
JWT_SECRET=
// 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_URL unset and expect the startup error, then set it and expect a successful connection. A fix that reads process.env and 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 log to 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 .env file, then committing that .env file because it was created before .gitignore was updated to exclude it - dotenv correctly 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_KEY or JWT_SECRET directly in a Dockerfile ENV instruction or a checked-in docker-compose.yml instead of injecting it at deploy time - process.env reads 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 allowing console.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