{*}SecureCodeHQ
5 Secret Mistakes on Your Server Right Now
·by Juan Isidoro·5 min read

5 Secret Mistakes on Your Server Right Now

Hardcoded JWT secrets, plaintext DB credentials, silent fallbacks. The most common Node.js security issues in production and how to fix each one.

securitysecretsnodejsbest-practices

Your application works. Users log in. Everything runs fine.

But somewhere on your server there's a JWT_SECRET that hasn't changed since day one. A DATABASE_URL sitting in plaintext. A default value that says "change-me" and nobody ever changed it.

These aren't hypothetical scenarios. They're the most common security issues I see in production Node.js servers. Here's what they are and how to fix them.


1. JWT secret in the codebase

The problem:

// "I'll fix it later"
const secret = process.env.JWT_SECRET || 'super-secret-key';

This fallback looks harmless. But if the environment variable fails to load (a misconfigured deploy, a missing .env, PM2 acting up) your app silently uses 'super-secret-key'. Anyone who reads your code or guesses a typical fallback can forge authentication tokens.

The fix:

Fail immediately if the secret doesn't exist.

const secret = process.env.JWT_SECRET;

if (!secret) {
  throw new Error('JWT_SECRET is required');
}

No fallback. No "we'll catch it in testing." If it's not there, the app doesn't start.


2. Database credentials in plaintext

The problem:

# .env (committed "by accident")
DATABASE_URL=postgres://admin:real_password_123@db.example.com:5432/production

Or worse: leaked through CI/CD logs, Docker build arguments, or PM2 environment variables that anyone with server access can see with pm2 env.

The fix:

Database credentials shouldn't exist as static files on your server. Fetch them at runtime from a secrets manager:

async function bootstrap() {
  const secrets = await secretsManager.getAll();
  process.env.DATABASE_URL = secrets.DATABASE_URL;

  // Now start the app
  const prisma = new PrismaClient({
    datasourceUrl: process.env.DATABASE_URL
  });
}

Credentials exist in memory only while the app is running. Nothing on disk that can leak.


3. Default values that "just work"

The problem:

const config = {
  jwtSecret: process.env.JWT_SECRET || 'change-me',
  encryptionKey: process.env.ENCRYPTION_KEY || 'default-key',
  apiKey: process.env.API_KEY || 'dev-key'
};

This is defensive programming done wrong. Your app boots without any issues using insecure values. No errors. No warnings. Just a false sense of security.

The fix:

Replace every fallback with validation:

const required = ['JWT_SECRET', 'ENCRYPTION_KEY', 'API_KEY'];

for (const key of required) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
}

Fail loudly. Fail early. Never let an insecure configuration reach production silently.


4. Secrets leaking through CI/CD

The problem:

# Your Dockerfile
ARG DATABASE_URL
RUN npx prisma generate

Build arguments are stored in Docker image layers. Anyone with access to your registry can extract them:

docker history your-image --no-trunc
# There's your DATABASE_URL in plaintext

The same happens with GitHub Actions logs if you accidentally echo a secret, or with Nixpacks builds that expose environment variables.

The fix:

Use BuildKit secrets that are never stored in the image:

RUN --mount=type=secret,id=database_url \
    DATABASE_URL=$(cat /run/secrets/database_url) \
    npx prisma generate

Or better: don't pass secrets at build time at all. Fetch them at runtime.


5. Secrets that never get rotated

The problem:

Your JWT_SECRET has the same value you generated two years ago. Maybe it was in a commit once. Maybe a former teammate saw it. Maybe it's in someone's notes.

If that secret leaks, every token ever issued is compromised. And you have no way to invalidate them without logging out every user.

The fix:

Design for rotation from day one:

const secrets = {
  current: process.env.JWT_SECRET_CURRENT,
  previous: process.env.JWT_SECRET_PREVIOUS
};

function verifyToken(token: string) {
  try {
    return jwt.verify(token, secrets.current);
  } catch {
    // Grace period: accept tokens signed with the previous secret
    return jwt.verify(token, secrets.previous);
  }
}

When you rotate, the old secret moves to previous. Existing tokens keep working during the transition window. New tokens use the new secret.


The pattern behind all five

These aren't five separate problems. They're symptoms of the same root cause: secrets living where they shouldn't.

  • In your code: hardcoded values
  • In your repo: committed .env files
  • In your CI/CD: exposed build arguments
  • On your server: static files on disk
  • In your history: values that never change

The solution is to treat secrets as external, fetched at runtime, and rotatable by default.


How SecureCodeHQ solves this

SecureCodeHQ is a secrets vault designed for exactly this workflow:

  1. Zero secrets on disk. Your server only stores a device token. All secrets are fetched at runtime.

  2. Fail-fast by design. If SecureCodeHQ can't load your secrets, your app doesn't start. No silent fallbacks.

  3. Built-in rotation. One click to rotate any secret, with automatic grace periods for existing tokens.

  4. Full audit trail. You know exactly when each secret was accessed and from which server.

import { SecureCode } from '@securecode/sdk';

async function bootstrap() {
  await SecureCode.loadEnv(); // Fetches all secrets

  // DATABASE_URL, JWT_SECRET, etc. are now in process.env
  // Nothing on disk. Nothing in your code.

  startApp();
}

Your secrets stay in the vault. Your servers stay clean. You sleep well.


Ready to clean up your secrets? Get started with SecureCodeHQ


Further reading