Back to Blog

SaaS Security Checklist: OWASP Top 10

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

Viprasol Tech Team
13 min read
Updated 2026

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

Quick answer. Most SaaS breaches come from unpatched dependencies, missing authorization checks, and secrets committed to Git, not sophisticated attacks. The highest-return controls map to the OWASP Top 10: authorization on every route with row-level security, TLS and encrypted secrets everywhere, parameterized queries, security headers, dependency scanning, and periodic 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 1000+ 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+.


security - SaaS Security Checklist: OWASP Top 10

🚀 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

---

## Partnering 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.

---

Additional Resources

securityowaspsaascomplianceappsec
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

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.