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?