Security Hardening: 9 Critical Fixes

January 6, 2026 5 min read

Security Audit Summary
4

Critical

5

High

9

Fixed

0

Remaining

The Audit Process

After completing the Node.js backend, we ran a comprehensive security audit. AI-assisted code review
found issues that would have taken hours to catch manually. Here’s what we found and how we fixed each one.

Critical Issues (4)

Issue #1
CRITICAL

JWT Secret Using Weak Fallback

The JWT configuration had a hardcoded fallback secret that would be used if the environment variable was missing.

Before (Vulnerable)
const JWT_SECRET = process.env.JWT_SECRET
  || 'fallback-secret-for-dev';

After (Fixed)
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
  throw new Error('JWT_SECRET required');
}

Impact: Attackers could forge authentication tokens using the known fallback.

Issue #2
CRITICAL

TOTP Secrets Stored in Plaintext

MFA TOTP secrets were stored in the database without encryption, defeating the purpose of the envelope encryption system.

Before
await db.user.update({
  where: { id: userId },
  data: {
    totpSecret: secret // Plaintext!
  }
});

After
const { ciphertext, iv } = encrypt(
  secret, userDek
);
await db.user.update({
  where: { id: userId },
  data: {
    totpSecretEncrypted: ciphertext,
    totpSecretIv: iv
  }
});

Impact: Database breach would expose all MFA secrets, allowing 2FA bypass.

Issue #3
CRITICAL

No Rate Limiting on Auth Endpoints

Login, signup, and MFA verification had no rate limiting, allowing brute force attacks.

// Added rate limiting middleware
import rateLimit from 'express-rate-limit';

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts
  message: { error: 'Too many attempts, try again later' },
  standardHeaders: true,
  legacyHeaders: false,
});

const mfaLimiter = rateLimit({
  windowMs: 5 * 60 * 1000, // 5 minutes
  max: 3, // 3 attempts
  message: { error: 'Too many MFA attempts' },
});

app.use('/api/auth/login', authLimiter);
app.use('/api/auth/signup', authLimiter);
app.use('/api/auth/verify-mfa', mfaLimiter);

Impact: Attackers could brute force passwords and TOTP codes.

Issue #4
CRITICAL

Master Encryption Key Too Short

Documentation suggested a 32-character MEK, but AES-256 requires a 256-bit (64 hex character) key.

Before
# .env.example
MASTER_ENCRYPTION_KEY=your-32-char-key

After
# .env.example
# 64 hex chars (256 bits)
MASTER_ENCRYPTION_KEY=<generate with:
openssl rand -hex 32>

Impact: Weak key could be brute forced, compromising all encrypted data.

High Priority Issues (5)

Issue #5
HIGH

No CSRF Protection

State-changing requests didn’t verify CSRF tokens, allowing cross-site request forgery.

// Added CSRF middleware
import csrf from 'csurf';

const csrfProtection = csrf({
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  }
});

// Apply to all state-changing routes
app.use('/api/entries', csrfProtection);
app.use('/api/pillars', csrfProtection);
app.use('/api/feels', csrfProtection);

// Endpoint to get CSRF token
app.get('/api/csrf-token', csrfProtection, (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

Issue #6
HIGH

Account Enumeration via Error Messages

Login errors revealed whether an email existed in the system.

Before
if (!user) {
  return res.status(404).json({
    error: 'User not found'
  });
}
if (!validPassword) {
  return res.status(401).json({
    error: 'Invalid password'
  });
}

After
if (!user || !validPassword) {
  // Constant-time response
  await bcrypt.compare(
    password,
    DUMMY_HASH
  );
  return res.status(401).json({
    error: 'Invalid credentials'
  });
}

Issue #7
HIGH

Missing Helmet Security Headers

Security headers like CSP, X-Frame-Options, and HSTS were not configured.

import helmet from 'helmet';

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));

Issue #8
HIGH

JWT Tokens Never Expire

Access tokens had no expiration, meaning stolen tokens were valid forever.

// Access tokens: 15 minutes
const accessToken = jwt.sign(
  { userId, type: 'access' },
  JWT_SECRET,
  { expiresIn: '15m' }
);

// Refresh tokens: 7 days
const refreshToken = jwt.sign(
  { userId, type: 'refresh' },
  JWT_SECRET,
  { expiresIn: '7d' }
);

// Refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  const decoded = jwt.verify(refreshToken, JWT_SECRET);

  if (decoded.type !== 'refresh') {
    return res.status(401).json({ error: 'Invalid token type' });
  }

  // Issue new access token
  const accessToken = jwt.sign(
    { userId: decoded.userId, type: 'access' },
    JWT_SECRET,
    { expiresIn: '15m' }
  );

  res.json({ accessToken });
});

Issue #9
HIGH

No Account Lockout After Failed Attempts

Even with rate limiting, there was no account-level lockout for repeated failures.

const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes

async function checkAccountLockout(userId: string) {
  const user = await db.user.findUnique({
    where: { id: userId },
    select: { failedAttempts: true, lockedUntil: true }
  });

  if (user.lockedUntil && user.lockedUntil > new Date()) {
    throw new Error('Account locked. Try again later.');
  }

  return user.failedAttempts;
}

async function recordFailedAttempt(userId: string) {
  const attempts = await checkAccountLockout(userId);

  const update: any = {
    failedAttempts: { increment: 1 }
  };

  if (attempts + 1 >= MAX_FAILED_ATTEMPTS) {
    update.lockedUntil = new Date(Date.now() + LOCKOUT_DURATION);
    update.failedAttempts = 0;
  }

  await db.user.update({
    where: { id: userId },
    data: update
  });
}

async function resetFailedAttempts(userId: string) {
  await db.user.update({
    where: { id: userId },
    data: { failedAttempts: 0, lockedUntil: null }
  });
}

Lessons Learned

Key Insights
  1. Security is never “done”: Even with careful design, issues slip through.
  2. AI code review helps: Claude caught issues in minutes that might take hours manually.
  3. Defense in depth: Multiple layers (rate limiting + lockout + CSRF) work together.
  4. Follow established patterns: Don’t reinvent auth – use proven libraries.

Security Checklist (Use for Your Projects)

  • No hardcoded secrets or fallbacks
  • All sensitive data encrypted at rest
  • Rate limiting on auth endpoints
  • CSRF protection on state-changing routes
  • Generic error messages (no enumeration)
  • Security headers via Helmet
  • JWT tokens with expiration
  • Account lockout after failed attempts
  • Input validation on all endpoints
Secret Link