
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.
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
.envfiles - 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:
-
Zero secrets on disk. Your server only stores a device token. All secrets are fetched at runtime.
-
Fail-fast by design. If SecureCodeHQ can't load your secrets, your app doesn't start. No silent fallbacks.
-
Built-in rotation. One click to rotate any secret, with automatic grace periods for existing tokens.
-
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
- Why .env files are dangerous with AI agents dives into the secret exposure problem when files sit on disk
- SSH for developers: keys, servers, and AI agents if you manage SSH keys across multiple environments
- How to prevent secrets from ending up in git for pre-commit hooks and scanning tools
- AI agent security: a practical guide covers the full threat model for coding agents
- Managing secrets with Claude Code for the practical vault setup
- Asymmetric cryptography: the idea that changed the internet for the math behind encryption and key pairs
- Try SecureCode free. Zero-knowledge secrets for developers using AI agents.