Skip to content

CWE-522: Insufficiently Protected Credentials - JavaScript

Overview

Insufficiently Protected Credentials in JavaScript/Node.js applications occurs when passwords, API keys, tokens, or other authentication secrets are stored in plaintext, weakly encrypted, hardcoded in source code, committed to version control with .env files, or transmitted insecurely. Node.js provides access to robust cryptographic libraries like bcrypt, argon2, and the built-in crypto module, but developers must actively integrate and configure these tools correctly.

Primary Defence: Use bcrypt or argon2 npm packages for password hashing with appropriate cost factors, and never store passwords in plaintext.

Common Vulnerable Patterns

Storing Passwords in Plaintext

// VULNERABLE - Plaintext password storage
const express = require('express');
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  username: { type: String, required: true, unique: true },
  password: { type: String, required: true }  // Plaintext!
});

const User = mongoose.model('User', userSchema);

app.post('/register', async (req, res) => {
  const { username, password } = req.body;

  // Storing password directly without hashing!
  const user = new User({
    username,
    password  // Plaintext password!
  });

  await user.save();
  res.json({ status: 'success' });
});

app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  const user = await User.findOne({ username });

  // Direct password comparison!
  if (user && user.password === password) {
    return res.json({ token: generateToken(user) });
  }

  res.status(401).json({ error: 'Invalid credentials' });
});

Why this is vulnerable:

  • Database access reveals all passwords immediately.
  • Password reuse turns one breach into many.

Hardcoded Credentials

// VULNERABLE - Credentials in source code
// config.js
module.exports = {
  database: {
    host: 'prod-database.company.com',
    port: 5432,
    user: 'admin',
    password: 'SuperSecret123!'  // NEVER DO THIS!
  },
  apiKeys: {
    stripe: 'sk_live_abc123def456ghi789',  // Hardcoded API key!
    sendgrid: 'SG.abc123def456'
  },
  jwtSecret: 'my-secret-key-12345'  // Hardcoded!
};

// Using hardcoded credentials
const { Pool } = require('pg');
const config = require('./config');

const pool = new Pool({
  host: config.database.host,
  user: config.database.user,
  password: config.database.password  // Exposed in code!
});

Why this is vulnerable:

  • Repo access or bundled artifacts can leak secrets.
  • Rotation requires code changes and redeploys.

Weak Password Hashing

// VULNERABLE - Using crypto.createHash (not for passwords!)
const crypto = require('crypto');

app.post('/register', async (req, res) => {
  const { username, password } = req.body;

  // MD5/SHA-256 without salt is BROKEN for passwords!
  const passwordHash = crypto
    .createHash('md5')  // or 'sha256'
    .update(password)
    .digest('hex');

  const user = new User({
    username,
    passwordHash  // Weak hash!
  });

Why this is vulnerable:

  • Fast hashes make brute-force practical.
  • No salt or work factor enables rainbow tables.

.env File in Git Repository

// VULNERABLE - .env committed to version control
// .env (accidentally committed to git!)
DATABASE_URL=postgresql://admin:password123@localhost/mydb
JWT_SECRET=my-secret-jwt-key
STRIPE_SECRET_KEY=sk_live_abc123def456
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

// .gitignore missing .env!
// If .env is in git history, credentials are permanently exposed

// app.js
require('dotenv').config();

const dbUrl = process.env.DATABASE_URL;  // Exposed if .env in git
const jwtSecret = process.env.JWT_SECRET;

Why this is vulnerable:

  • Secrets persist in git history and forks.
  • Accidental public pushes are common and hard to fully undo.

Insecure JWT Implementation

// VULNERABLE - Weak JWT secret and long expiration
const jwt = require('jsonwebtoken');

// Weak, hardcoded secret!
const JWT_SECRET = 'secret';
const EXPIRATION = '365d';  // 1 year is too long!

function createToken(user) {
  // Including password hash in token!
  return jwt.sign(

Why this is vulnerable:

  • Weak secrets allow token forgery.
  • Long expiry and sensitive claims increase impact of leaks.

Frontend Credential Exposure

// VULNERABLE - API keys in frontend code
// React/Vue/Angular component
const API_KEY = 'sk_live_abc123def456';  // Exposed in browser!

fetch('https://api.service.com/data', {
  headers: {
    'Authorization': `Bearer ${API_KEY}`  // Visible in DevTools!
  }
});

// Or in environment variables bundled by webpack/vite
// .env (client-side)
VITE_API_KEY=sk_live_abc123def456
REACT_APP_API_KEY=sk_live_abc123def456

// These get bundled into client-side code!
const apiKey = import.meta.env.VITE_API_KEY;

Secure Patterns

Bcrypt Password Hashing

// SECURE - Proper bcrypt implementation
const express = require('express');
const bcrypt = require('bcrypt');
const mongoose = require('mongoose');

const SALT_ROUNDS = 12;  // Recommended minimum

const userSchema = new mongoose.Schema({
  username: { type: String, required: true, unique: true },
  passwordHash: { type: String, required: true }
});

const User = mongoose.model('User', userSchema);

app.post('/register', async (req, res) => {
  try {
    const { username, password } = req.body;

    // Generate salt and hash password
    const salt = await bcrypt.genSalt(SALT_ROUNDS);
    const passwordHash = await bcrypt.hash(password, salt);

    const user = new User({
      username,
      passwordHash
    });

    await user.save();
    res.json({ status: 'success', userId: user._id });
  } catch (error) {
    logger.error('Registration error:', error);
    res.status(500).json({ error: 'Registration failed' });
  }
});

app.post('/login', async (req, res) => {
  try {
    const { username, password } = req.body;

    const user = await User.findOne({ username });

    if (!user) {
      // Generic error - don't reveal if user exists
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Compare password with hash
    const isValid = await bcrypt.compare(password, user.passwordHash);

    if (isValid) {
      const token = createSecureToken(user._id);
      return res.json({ token });
    }

    res.status(401).json({ error: 'Invalid credentials' });
  } catch (error) {
    logger.error('Login error:', error);
    res.status(500).json({ error: 'Login failed' });
  }
});

Why this works:

  • Per-password salts and a tunable cost factor make offline cracking expensive.
  • Async bcrypt keeps the event loop responsive while using standard hash formats.

HashiCorp Vault Integration

// SECURE - Vault for secrets management
const vault = require('node-vault');

class VaultSecretsManager {
  constructor() {
    this.client = vault({
      apiVersion: 'v1',
      endpoint: process.env.VAULT_ADDR || 'http://localhost:8200',
      token: process.env.VAULT_TOKEN
    });
  }

  async getSecret(path) {
    try {
      const result = await this.client.read(path);
      return result.data.data;
    } catch (error) {
      logger.error(`Failed to retrieve secret: ${path}`);
      throw new Error('Secret retrieval failed');
    }
  }

  async getDatabaseCredentials() {
    const secrets = await this.getSecret('secret/data/database/postgresql');
    return {
      host: secrets.host,
      port: secrets.port,
      user: secrets.username,
      password: secrets.password,
      database: secrets.database
    };
  }

  async getApiKey(service) {
    const secrets = await this.getSecret(`secret/data/api-keys/${service}`);
    return secrets.key;
  }
}

// Usage
const vaultSecrets = new VaultSecretsManager();

async function initializeDatabase() {
  const dbCreds = await vaultSecrets.getDatabaseCredentials();

  const { Pool } = require('pg');
  const pool = new Pool({
    host: dbCreds.host,
    port: dbCreds.port,
    user: dbCreds.user,
    password: dbCreds.password,
    database: dbCreds.database
  });

  return pool;
}

Why this works:

  • Secrets are fetched at runtime and encrypted at rest, staying out of code and repos.
  • Policies and short-lived tokens enable least privilege and rotation.

AWS Secrets Manager Integration

// SECURE - AWS Secrets Manager
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');

class AWSSecretsManager {
  constructor(region = 'us-east-1') {
    this.client = new SecretsManagerClient({ region });
  }

  async getSecret(secretName) {
    try {
      const command = new GetSecretValueCommand({
        SecretId: secretName
      });

      const response = await this.client.send(command);

      if (response.SecretString) {
        return JSON.parse(response.SecretString);
      } else {
        // Binary secret
        const buffer = Buffer.from(response.SecretBinary, 'base64');
        return buffer.toString('ascii');
      }
    } catch (error) {
      logger.error(`Failed to retrieve secret: ${secretName}`, error);
      throw error;
    }
  }
}

// Usage
const secretsManager = new AWSSecretsManager('us-east-1');

async function initializeApp() {
  // Get database credentials
  const dbSecret = await secretsManager.getSecret('production/database');
  const DATABASE_URL = `postgresql://${dbSecret.username}:${dbSecret.password}@${dbSecret.host}/${dbSecret.database}`;

  // Get API keys
  const apiKeys = await secretsManager.getSecret('production/api-keys');
  const STRIPE_KEY = apiKeys.stripe_key;
  const SENDGRID_KEY = apiKeys.sendgrid_key;

  return { DATABASE_URL, STRIPE_KEY, SENDGRID_KEY };
}

Why this works:

  • Secrets are encrypted with KMS and accessed at runtime via IAM roles.
  • Rotation and CloudTrail logging reduce exposure and support auditing.

Secure JWT Implementation

// SECURE - Proper JWT with strong secret
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// Generate strong secret (run once, store in secrets manager)
function generateJwtSecret() {
  return crypto.randomBytes(64).toString('hex');
}

// Load secret from secure storage
let JWT_SECRET;
let JWT_REFRESH_SECRET;

async function initializeSecrets() {
  JWT_SECRET = await vaultSecrets.getSecret('jwt/access-secret');
  JWT_REFRESH_SECRET = await vaultSecrets.getSecret('jwt/refresh-secret');
}

const ACCESS_TOKEN_EXPIRY = '1h';  // Short-lived
const REFRESH_TOKEN_EXPIRY = '30d';  // Longer for refresh

function createAccessToken(userId, role) {
  // Minimal claims - no sensitive data
  return jwt.sign(
    {
      userId,
      role,
      type: 'access'
    },
    JWT_SECRET,
    {
      expiresIn: ACCESS_TOKEN_EXPIRY,
      issuer: 'myapp',
      audience: 'myapp-users'
    }
  );
}

function createRefreshToken(userId) {
  return jwt.sign(
    {
      userId,
      type: 'refresh'
    },
    JWT_REFRESH_SECRET,
    {
      expiresIn: REFRESH_TOKEN_EXPIRY,
      issuer: 'myapp',
      audience: 'myapp-users'
    }
  );
}

function verifyAccessToken(token) {
  try {
    const payload = jwt.verify(token, JWT_SECRET, {
      issuer: 'myapp',
      audience: 'myapp-users'
    });

    if (payload.type !== 'access') {
      throw new Error('Invalid token type');
    }

    return payload;
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      throw new Error('Token expired');
    }
    throw new Error('Invalid token');
  }
}

app.post('/token/refresh', async (req, res) => {
  try {
    const { refreshToken } = req.body;

    const payload = jwt.verify(refreshToken, JWT_REFRESH_SECRET);

    if (payload.type !== 'refresh') {
      throw new Error('Invalid token type');
    }

    // Generate new access token
    const accessToken = createAccessToken(payload.userId, payload.role);

    res.json({ accessToken });
  } catch (error) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

Why this works:

  • Strong secrets, short-lived access tokens, and minimal claims limit blast radius.
  • Issuer/audience checks and separate refresh secret tighten validation.

HTTPS Enforcement

// SECURE - Force HTTPS in Express
const express = require('express');
const helmet = require('helmet');
const https = require('https');
const fs = require('fs');

const app = express();

// Use Helmet for security headers
app.use(helmet({
  hsts: {
    maxAge: 31536000,  // 1 year
    includeSubDomains: true,
    preload: true
  }
}));

// Middleware to enforce HTTPS
app.use((req, res, next) => {
  if (!req.secure && process.env.NODE_ENV === 'production') {
    return res.redirect(301, `https://${req.headers.host}${req.url}`);
  }
  next();
});

// Production HTTPS server
if (process.env.NODE_ENV === 'production') {
  const options = {
    key: fs.readFileSync('/path/to/private-key.pem'),
    cert: fs.readFileSync('/path/to/certificate.pem')
  };

  https.createServer(options, app).listen(443, () => {
    console.log('HTTPS server running on port 443');
  });

  // Redirect HTTP to HTTPS
  express().get('*', (req, res) => {
    res.redirect(301, `https://${req.headers.host}${req.url}`);
  }).listen(80);
} else {
  app.listen(3000, () => {
    console.log('Development server on port 3000');
  });
}

Why this works:

  • TLS encrypts credentials in transit and prevents eavesdropping.
  • HSTS and redirects ensure HTTP is not accepted in production.

Argon2 Password Hashing

// SECURE - Argon2 (most secure option)
const argon2 = require('argon2');

const ARGON2_OPTIONS = {
  type: argon2.argon2id,  // Recommended variant
  memoryCost: 65536,      // 64 MB
  timeCost: 3,            // Iterations
  parallelism: 1
};

app.post('/register', async (req, res) => {
  try {
    const { username, password } = req.body;

    // Hash password with Argon2
    const passwordHash = await argon2.hash(password, ARGON2_OPTIONS);

    const user = new User({
      username,
      passwordHash
    });

    await user.save();
    res.json({ status: 'success' });
  } catch (error) {
    logger.error('Registration error:', error);
    res.status(500).json({ error: 'Registration failed' });
  }
});

app.post('/login', async (req, res) => {
  try {
    const { username, password } = req.body;

    const user = await User.findOne({ username });

    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Verify password with Argon2
    const isValid = await argon2.verify(user.passwordHash, password);

    if (isValid) {
      const token = createAccessToken(user._id);
      return res.json({ token });
    }

    res.status(401).json({ error: 'Invalid credentials' });
  } catch (error) {
    logger.error('Login error:', error);
    res.status(500).json({ error: 'Login failed' });
  }
});

Why this works:

  • Argon2id is memory-hard and resists GPU/ASIC cracking.
  • PHC-formatted hashes and tunable parameters allow future upgrades.

Environment Variable Best Practices

// SECURE - Proper .env usage
// .env (NEVER commit to git!)
DATABASE_URL=postgresql://localhost/mydb
JWT_SECRET=<load-from-secrets-manager>

// .env.example (safe to commit as template)
DATABASE_URL=postgresql://user:password@localhost/dbname
JWT_SECRET=your_jwt_secret_here

// .gitignore
.env
.env.local
.env.*.local
*.pem
*.key

// app.js
require('dotenv').config();

// Validate required environment variables
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET'];
for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    throw new Error(`Missing required environment variable: ${envVar}`);
  }
}

// Validate JWT secret strength
if (process.env.JWT_SECRET.length < 32) {
  throw new Error('JWT_SECRET must be at least 32 characters');
}

Why this works:

  • Secrets stay out of code and repos via environment injection.
  • .env.example and startup validation prevent missing or weak config.

Verification

To verify credentials are properly protected:

  • Check password storage: Query the database to confirm passwords are hashed with bcrypt or Argon2 (look for $2a$, $2b$, or $argon2 prefix), not stored in plaintext
  • Confirm salt usage: Verify the same password for different users produces different hashes
  • Review configuration: Check .env files are not committed to version control and all sensitive values use environment variables
  • Test JWT tokens: Verify tokens include expiration claims and expire within reasonable timeframes
  • Search codebase: Look for hardcoded API keys, passwords, or secrets in source files
  • Test authentication: Confirm login works correctly with valid credentials and rejects invalid ones
  • Scan dependencies: Use npm audit to check for known vulnerabilities in authentication libraries
// SECURE - Tests to verify credential protection
const request = require('supertest');
const bcrypt = require('bcrypt');
const app = require('../app');
const User = require('../models/User');

describe('Credential Security Tests', () => {
  test('passwords are hashed in database', async () => {
    const password = 'MyPassword123!';

    const response = await request(app)
      .post('/register')
      .send({
        username: 'testuser',
        password
      });

    expect(response.status).toBe(200);

    // Check database directly
    const user = await User.findOne({ username: 'testuser' });

    // Password should not be plaintext
    expect(user.passwordHash).not.toBe(password);

    // Should look like bcrypt hash
    expect(user.passwordHash).toMatch(/^\$2[aby]\$\d+\$/);

    // Should verify correctly
    const isValid = await bcrypt.compare(password, user.passwordHash);
    expect(isValid).toBe(true);
  });

  test('same password produces different hashes', async () => {
    const password = 'SamePassword123!';

    await request(app).post('/register').send({
      username: 'user1',
      password
    });

    await request(app).post('/register').send({
      username: 'user2',
      password
    });

    const user1 = await User.findOne({ username: 'user1' });
    const user2 = await User.findOne({ username: 'user2' });

    // Different salts produce different hashes
    expect(user1.passwordHash).not.toBe(user2.passwordHash);
  });

  test('no hardcoded secrets in code', () => {
    const fs = require('fs');
    const path = require('path');

    // Check main application files
    const appFiles = [
      'app.js',
      'config.js',
      'routes/auth.js'
    ];

    for (const file of appFiles) {
      const content = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');

      // Should not contain obvious hardcoded secrets
      expect(content).not.toMatch(/password\s*=\s*['"][^'"]+['"]/i);
      expect(content).not.toMatch(/secret\s*=\s*['"](?!process\.env)[^'"]+['"]/i);
      expect(content).not.toMatch(/api_key\s*=\s*['"][^'"]+['"]/i);
    }
  });

  test('JWT tokens expire', () => {
    const jwt = require('jsonwebtoken');
    const userId = '123';

    const token = createAccessToken(userId);

    // Decode without verification
    const decoded = jwt.decode(token);

    // Must have expiration
    expect(decoded.exp).toBeDefined();

    // Should expire in the future
    const now = Math.floor(Date.now() / 1000);
    expect(decoded.exp).toBeGreaterThan(now);

    // But not too far (< 2 hours)
    const twoHours = now + (2 * 60 * 60);
    expect(decoded.exp).toBeLessThan(twoHours);
  });

  test('.env file not committed to git', () => {
    const fs = require('fs');

    // Check .gitignore exists
    const gitignore = fs.readFileSync('.gitignore', 'utf8');

    // Should ignore .env files
    expect(gitignore).toMatch(/\.env/);
    expect(gitignore).toMatch(/\.env\.local/);
  });
});

Additional Resources