When I ran OWASP ZAP against my own app, I expected a clean report. I'd been careful about security from day one โ parameterised queries, proper authentication, HTTPS everywhere.
Instead, I found 3 medium-severity issues in the first scan.
That scan taught me something important: there's a massive gap between "secure" and "provably secure." The first means you haven't been hacked yet. The second means you can demonstrate to an auditor, a customer, or a regulator that your systems are hardened, logged, and defensible.
Here's the checklist I built after going through this process. Score yourself honestly.
1. Encryption at Rest โ Not Just HTTPS
Most developers stop at HTTPS. "Data is encrypted in transit โ we're good."
Auditors ask a different question: Is PII encrypted in your database?
If someone gains database access โ a leaked backup, a compromised admin account, a SQL injection you missed โ can they read your users' email addresses, phone numbers, and payment references in plain text?
Field-level encryption is the answer. AES-256-GCM is the standard for SOC 2 and enterprise compliance.
Here's the pattern I use:
typescript
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex'); // 32 bytes
export function encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}
export function decrypt(encryptedText: string): string {
const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}What to encrypt: email addresses, phone numbers, payment references, any PII. Not everything โ just the fields that would cause damage if exposed.
Quick test: If you dumped your database right now, could someone read your users' emails? If yes, you'd fail this check.
2. Audit Logging โ The Thing That Makes or Breaks Audits
Logging is not console.log("User logged in"). Audit-grade logging means a complete, immutable, queryable trail of every significant action in your system.
Here's the schema I use:
prisma
model AuditLog {
id String @id @default(cuid())
userId String?
action String // LOGIN_SUCCESS, LOGIN_FAILURE, DATA_EXPORT, USER_DELETE
entity String // USER, PAYMENT, SETTINGS
entityId String?
before Json? // State before the change
after Json? // State after the change
ipAddress String
userAgent String
requestId String // Correlation ID for tracing
metadata Json?
createdAt DateTime @default(now())
@@index([userId])
@@index([action])
@@index([entity, entityId])
@@index([createdAt])
}The key properties auditors look for:
- Completeness: Every login attempt (success AND failure), every data modification (with before/after values), every admin action.
- Immutability: Append-only. No updates, no deletes. If someone tampers with logs, you've lost your evidence.
- Retention: 7 years for financial data. Configurable per data type.
- Queryable: "Show me every action User X took in the last 30 days." If you can't answer this in seconds, your logging isn't audit-ready.
The service function:
typescript
export async function createAuditLog(data: {
userId?: string;
action: string;
entity: string;
entityId?: string;
before?: any;
after?: any;
ipAddress: string;
userAgent: string;
requestId: string;
}) {
return prisma.auditLog.create({
data: {
...data,
before: data.before ? JSON.parse(JSON.stringify(data.before)) : undefined,
after: data.after ? JSON.parse(JSON.stringify(data.after)) : undefined,
},
});
}Quick test: Can you tell me exactly what admin actions were performed on your system last Tuesday? If not, you'd fail this check.
3. Rate Limiting Per Endpoint
Global rate limiting โ "100 requests per minute per IP" โ is a start. But it's not enough.
Each endpoint has different attack vectors and different acceptable thresholds:
typescript
import rateLimit from 'express-rate-limit';
// Login: prevent brute force
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
keyGenerator: (req) => req.ip,
message: 'Too many login attempts. Try again in 15 minutes.',
});
// Password reset: prevent email bombing
export const passwordResetLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
keyGenerator: (req) => req.body.email || req.ip,
message: 'Too many password reset requests.',
});
// API: general usage
export const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100,
keyGenerator: (req) => req.user?.id || req.ip,
});
// Registration: prevent mass account creation
export const registrationLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 10,
keyGenerator: (req) => req.ip,
});
// MFA verification: prevent code guessing
export const mfaLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
keyGenerator: (req) => req.sessionID || req.ip,
});Apply them individually:
typescript
app.post('/api/auth/login', loginLimiter, authController.login);
app.post('/api/auth/reset-password', passwordResetLimiter, authController.resetPassword);
app.post('/api/auth/verify-mfa', mfaLimiter, authController.verifyMfa);
app.post('/api/auth/register', registrationLimiter, authController.register);
app.use('/api/', apiLimiter);Quick test: Could someone attempt 1,000 logins to your API right now? If yes, you'd fail this check.
4. PII in Logs โ The Silent Compliance Killer
I found this pattern in a real production codebase:
typescript
// โ WRONG โ this logs passwords and emails in plain text
logger.info("User login attempt:", { email, password });Passwords. In. Logs. Sitting in CloudWatch, Datadog, or a log file on a server somewhere, accessible to anyone with log access.
The fix is a PII masking format for your logger:
typescript
import winston from 'winston';
const sensitiveFields = ['password', 'token', 'secret', 'authorization',
'creditCard', 'ssn', 'refreshToken'];
const emailRegex = /([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
const piiMaskingFormat = winston.format((info) => {
const masked = JSON.parse(JSON.stringify(info));
// Mask sensitive fields
for (const field of sensitiveFields) {
if (masked[field]) {
masked[field] = '[REDACTED]';
}
}
// Mask email addresses
const str = JSON.stringify(masked);
const cleaned = str.replace(emailRegex, (match, local, domain) => {
return `${local[0]}***@${domain}`;
});
return JSON.parse(cleaned);
});
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
piiMaskingFormat(),
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
],
});Now logger.info("Login:", { email: "john@example.com", password: "secret123" }) outputs:
json
{ "message": "Login:", "email": "j***@example.com", "password": "[REDACTED]" }Quick test: Search your logs for @gmail.com or @yahoo.com right now. If you find full email addresses, you have a GDPR problem.
5. Security Headers โ 2 Lines of Code, Massive Impact
Helmet.js handles this in Express:
typescript
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,
},
}));This gives you: X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, Content-Security-Policy, and more. Each header blocks a specific class of attacks.
CORS configuration matters too:
typescript
import cors from 'cors';
app.use(cors({
origin: process.env.FRONTEND_URL, // NOT "*"
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));origin: "*" is the most common security misconfiguration I see in SaaS codebases. It means any website can make authenticated requests to your API.
Quick test: Run curl -I https://your-app.com and check the response headers. No Strict-Transport-Security? You'd fail.
6. Input Validation at the Edge
Every request should be validated before it reaches your controller. Not inside the controller โ at the middleware layer:
typescript
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
// Define schema
const registerSchema = z.object({
email: z.string().email(),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[a-z]/, 'Must contain lowercase letter')
.regex(/[0-9]/, 'Must contain number'),
name: z.string().min(1).max(100),
});
// Validation middleware factory
export function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
success: false,
errors: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
});
}
req.body = result.data; // Use parsed/cleaned data
next();
};
}
// Usage
app.post('/api/auth/register',
registrationLimiter,
validate(registerSchema),
authController.register
);Your controller now receives guaranteed clean, typed data. Zero "cannot read property of undefined" errors. And Prisma's parameterised queries handle SQL injection protection at the ORM layer.
Quick test: Send {"email": "not-an-email", "password": "1"} to your registration endpoint. Does it return a clear validation error, or does it crash?
Security Audit Readiness Score
Score yourself honestly. 10 points each:
#CheckY/N1PII is encrypted at rest (not just HTTPS)2Audit logs capture every login attempt (success + failure)3Audit logs include before/after values for data changes4Logs are append-only (immutable)5Rate limiting is per-endpoint, not just global6No PII in log files (emails, passwords, tokens)7Security headers set via Helmet.js (or equivalent)8CORS is configured to specific origins (not "*")9All input is validated at the middleware layer10You can query "what did User X do last Tuesday?" in seconds
Scoring:
- 90โ100: Audit-ready. You're ahead of most SaaS products.
- 60โ80: Solid foundation, gaps to close. Prioritise encryption and logging.
- 30โ50: Significant work needed. Start with Helmet.js and rate limiting โ quickest wins.
- 0โ20: Honest. And fixable. Every item above can be built in a day or less.
The difference between "secure" and "audit-ready" is documentation and traceability. If you can't prove it's secure, it's not secure enough.
Run these checks against your own app. Score honestly.
What did you get?