Back to Blog

SaaS Security Checklist: OWASP Top 10, Security Headers, Dependency Scanning, and Pen Testing

Comprehensive SaaS security checklist for 2026 — OWASP Top 10 mitigations, HTTP security headers, dependency vulnerability scanning, secrets management, CSP con

Viprasol Tech Team
June 4, 2026
13 min read

SaaS Security Checklist: OWASP Top 10, Security Headers, Dependency Scanning, and Pen Testing

Security for SaaS products isn't a single feature — it's a set of controls applied consistently across the stack. Most SaaS security breaches aren't the result of sophisticated attacks; they're unpatched dependencies, missing authorization checks, and secrets committed to Git.

This checklist covers the controls with the highest return per engineering hour.


OWASP Top 10: 2025 Edition

RiskDescriptionPrimary Mitigation
A01: Broken Access ControlUsers accessing data/actions they shouldn'tAuthorization checks on every route, row-level security
A02: Cryptographic FailuresSensitive data exposed due to weak/missing encryptionTLS everywhere, encrypted secrets, bcrypt for passwords
A03: InjectionSQL, command, and other injection attacksParameterized queries, ORMs, input validation
A04: Insecure DesignMissing threat modeling, unsafe business logicDesign-time review, abuse case testing
A05: Security MisconfigurationDefault configs, verbose error messages, open portsHardened defaults, IaC security checks
A06: Vulnerable ComponentsOutdated libraries with known CVEsAutomated dependency scanning, update policies
A07: Authentication FailuresWeak passwords, broken session managementMFA, secure session handling, account lockout
A08: Software Integrity FailuresCI/CD pipeline compromise, unsigned packagesPinned dependencies, signed releases, SLSA
A09: Security Logging FailuresNo audit trail for security eventsLog authentication events, access to sensitive data
A10: SSRFServer-side requests to internal resourcesURL allowlisting, block private IP ranges

A01: Authorization — The Most Common Failure

Authorization is the most frequently broken control. Every API endpoint needs:

  1. Authentication check (is there a valid session?)
  2. Authorization check (is this user allowed to do this thing?)
// middleware/authorize.ts
import type { FastifyRequest, FastifyReply } from 'fastify';

interface AuthorizeOptions {
  resource: string;
  action: 'read' | 'write' | 'delete' | 'admin';
}

export function authorize(opts: AuthorizeOptions) {
  return async (request: FastifyRequest, reply: FastifyReply) => {
    const user = request.user;  // Set by JWT auth middleware

    if (!user) {
      return reply.code(401).send({ error: 'Authentication required' });
    }

    const allowed = await checkPermission({
      userId: user.id,
      tenantId: user.tenantId,
      resource: opts.resource,
      action: opts.action,
    });

    if (!allowed) {
      // Log authorization failure — this is a security event
      request.log.warn({
        event: 'authorization_denied',
        userId: user.id,
        tenantId: user.tenantId,
        resource: opts.resource,
        action: opts.action,
        path: request.url,
      });
      return reply.code(403).send({ error: 'Forbidden' });
    }
  };
}

// Route with authorization
app.delete('/api/documents/:id',
  { preHandler: [authenticate, authorize({ resource: 'documents', action: 'delete' })] },
  async (request, reply) => {
    const doc = await db.documents.findById(request.params.id);

    // CRITICAL: verify the document belongs to the current tenant
    // Authorization middleware checked role, but not ownership
    if (doc.tenantId !== request.user.tenantId) {
      throw new ForbiddenError('Document not in your organization');
    }

    await db.documents.delete(request.params.id);
    return reply.code(204).send();
  }
);

The insidious form of A01: Role check passes, but object ownership check is missing. User A can delete User B's documents because the delete endpoint only checks "is user an admin?" not "does the document belong to this user's tenant?"


🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

A03: Injection — Parameterized Queries Always

// ❌ NEVER: String interpolation in SQL
const user = await db.query(`SELECT * FROM users WHERE email = '${email}'`);

// ✅ Parameterized query — always safe
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);

// ✅ ORM handles parameterization automatically
const user = await prisma.user.findUnique({ where: { email } });

// ❌ NEVER: OS command injection
const output = exec(`convert ${filename} -resize 200x200 output.jpg`);

// ✅ Pass arguments as array (never shell-interpolated)
const output = execFile('convert', [filename, '-resize', '200x200', 'output.jpg']);

HTTP Security Headers

Security headers are the fastest security wins — configure once, protect all pages:

// middleware/security-headers.ts (Fastify)
app.addHook('onSend', async (request, reply) => {
  // Prevent clickjacking
  reply.header('X-Frame-Options', 'DENY');

  // Prevent MIME type sniffing
  reply.header('X-Content-Type-Options', 'nosniff');

  // Force HTTPS for 1 year, including subdomains
  reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');

  // Referrer policy — don't leak auth tokens in Referer header
  reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');

  // Permissions policy — disable unused browser APIs
  reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');

  // Content Security Policy
  reply.header('Content-Security-Policy', [
    "default-src 'self'",
    "script-src 'self' 'nonce-{NONCE}'",  // Replace {NONCE} with per-request nonce
    "style-src 'self' 'unsafe-inline'",   // unsafe-inline needed for Tailwind in prod
    "img-src 'self' data: https:",
    "font-src 'self'",
    "connect-src 'self' https://api.yourproduct.com wss://api.yourproduct.com",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "upgrade-insecure-requests",
  ].join('; '));
});

Test your headers: securityheaders.com will grade your current headers. Aim for A+.


🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Secrets Management

Secrets committed to Git are the most common cause of cloud account compromise:

# Install git-secrets to prevent commits
brew install git-secrets
git secrets --install  # Install hooks in current repo
git secrets --register-aws  # Add AWS key patterns

# Or use detect-secrets
pip install detect-secrets
detect-secrets scan > .secrets.baseline
# Add to pre-commit hook:
detect-secrets-hook --baseline .secrets.baseline
// ✅ Load secrets from environment (set by AWS Secrets Manager, 1Password, etc.)
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) throw new Error('STRIPE_SECRET_KEY is not set');

// For AWS: use Secrets Manager SDK in production
// secrets are injected into ECS task environment at runtime
// Never store them in task definition JSON or Terraform state
# terraform/ecs-task.tf
# ✅ Reference secrets from Secrets Manager — value never appears in Terraform
resource "aws_ecs_task_definition" "api" {
  # ...
  container_definitions = jsonencode([{
    name = "api"
    secrets = [
      {
        name      = "STRIPE_SECRET_KEY"
        valueFrom = "arn:aws:secretsmanager:us-east-1:xxx:secret:prod/stripe-key"
      },
      {
        name      = "DATABASE_URL"
        valueFrom = "arn:aws:secretsmanager:us-east-1:xxx:secret:prod/database-url"
      }
    ]
  }])
}

Dependency Scanning

# .github/workflows/security.yml
name: Security Scan
on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: '0 6 * * 1'  # Weekly Monday morning scan

jobs:
  dependency-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: pnpm audit
        run: pnpm audit --audit-level=high
        # Fails CI if high or critical vulnerabilities exist

      - name: Snyk vulnerability scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Semgrep SAST
        uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/typescript
            p/owasp-top-ten
            p/sql-injection
        env:
          SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}

  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - name: Gitleaks secret scan
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Rate Limiting and Brute Force Prevention

// lib/rate-limit.ts
import { Redis } from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

export async function checkRateLimit(
  identifier: string,  // IP or user ID
  action: string,      // 'login', 'api', 'password-reset'
  limit: number,
  windowSeconds: number,
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
  const key = `rate:${action}:${identifier}`;
  const now = Date.now();
  const windowStart = now - windowSeconds * 1000;

  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(key, 0, windowStart);
  pipeline.zadd(key, now, `${now}`);
  pipeline.zcard(key);
  pipeline.expire(key, windowSeconds);
  const results = await pipeline.exec();

  const count = (results?.[2]?.[1] as number) ?? 0;
  const allowed = count <= limit;
  const resetAt = now + windowSeconds * 1000;

  if (!allowed) {
    // Log brute force attempt
    console.warn({ event: 'rate_limit_exceeded', identifier, action, count });
  }

  return { allowed, remaining: Math.max(0, limit - count), resetAt };
}

// Protect login endpoint
app.post('/api/auth/login', async (request, reply) => {
  const ip = request.ip;
  const { email } = request.body as { email: string };

  // 5 attempts per 15 minutes per IP
  const ipLimit = await checkRateLimit(ip, 'login', 5, 900);
  if (!ipLimit.allowed) {
    reply.header('Retry-After', String(Math.ceil((ipLimit.resetAt - Date.now()) / 1000)));
    return reply.code(429).send({ error: 'Too many login attempts. Try again later.' });
  }

  // 10 attempts per hour per email (catch distributed brute force)
  const emailLimit = await checkRateLimit(email, 'login-email', 10, 3600);
  if (!emailLimit.allowed) {
    return reply.code(429).send({ error: 'Too many login attempts for this account.' });
  }

  // ...proceed with auth
});

Security Review Checklist

Before any feature ships:

Pre-Ship Security Checklist

Authentication & Authorization

  • All API routes require authentication (explicit opt-out for public routes)
  • Authorization checks verify resource ownership, not just role
  • Admin endpoints require explicit admin role check
  • JWT tokens have appropriate expiry (15-60 min for access tokens)

Data Handling

  • No sensitive data (passwords, keys, PII) logged
  • Database queries use parameterized statements or ORM
  • File uploads validated: type, size, content (magic bytes check)
  • User-provided HTML sanitized with DOMPurify before rendering

Secrets

  • No secrets in source code, comments, or config files
  • git secrets scan passes
  • New secrets added to Secrets Manager, not .env files

HTTP

  • Security headers present (test with securityheaders.com)
  • CORS configured for specific origins (not *)
  • Rate limiting on auth and sensitive endpoints
  • HTTPS enforced (no HTTP in production)

Dependencies

  • pnpm audit passes with no high/critical
  • No new GPL-licensed libraries added without legal review

---

## Working With Viprasol

We conduct security reviews for SaaS products — OWASP Top 10 audits, security header configuration, dependency scanning setup, secrets management migration, authorization model review, and penetration test preparation.

→ [Talk to our team](/contact) about application security review.

---

See Also

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.