Security Audit Summary
Critical
High
Fixed
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)
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.
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.
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.
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)
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() });
});
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'
});
}
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' }
}));
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 });
});
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
- Security is never “done”: Even with careful design, issues slip through.
- AI code review helps: Claude caught issues in minutes that might take hours manually.
- Defense in depth: Multiple layers (rate limiting + lockout + CSRF) work together.
- 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