---
title: "Run a security audit of a small SaaS"
excerpt: "10-step playbook to find the bugs a $30k pen test would find for $0: OWASP Top 10, the ASVS subset that matters, dependency CVEs, secrets hygiene."
category: "Template"
---

# Run a security audit of a small SaaS

    A 10-step playbook. Open in Dock and you'll get four surfaces seeded:

    - **Steps** (table) - the 10 categories, owner + status
    - **Pointers** (table) - linked OWASP, ASVS, vendor docs
    - **Findings** (table) - one row per finding, severity + owner + due
    - **Brief** (doc) - executive summary + threat model + final remediation plan

    Read `Steps` top-to-bottom on first audit. On re-audits (quarterly), only run the steps that changed since last time.

## Outcome

A written, prioritised list of security findings on your SaaS, mapped to OWASP categories, with severity ratings and a remediation plan. Plus the tooling to re-run the audit quarterly without rebuilding from scratch.

**Estimated time:** 1 week first audit, 1-2 days quarterly re-audits  
**Difficulty:** advanced  
**For:** Solo founders + tech leads who can't afford a $30k pen test.

## What you'll need

Pre-register or install before you start.

- **[OWASP ZAP](https://www.zaproxy.org/)** _(Free open source)_ — Free open-source web app scanner. Catches the obvious low-hanging fruit.
- **[Semgrep](https://semgrep.dev/)** _(Free open source, $40/dev/mo Pro for org-wide rules)_ — Static analysis with security rule packs. Catches IDOR, raw SQL, missing auth.
- **[GitHub Advanced Security or Snyk](https://snyk.io/)** _(Free for public repos / open source, $25/dev/mo Snyk Team)_ — Dependency CVE scanning + secret scanning across the repo.
- **[trufflehog](https://github.com/trufflesecurity/trufflehog)** _(Free open source)_ — Secret scanning across repos and history; catches committed credentials.
- **[Burp Suite Community](https://portswigger.net/burp/communitydownload)** _(Free Community, $475/year Professional)_ — Manual web request inspector for IDOR + auth bypass testing.

---

# The template · 10 steps

## Step 1: Build the threat model and asset inventory first

_Estimated time: 2-3 hr_

An audit without a threat model is a checklist run blindfolded. Spend an hour on STRIDE: what does the app do, what data does it hold, who would attack it, what would they want? The output is a list of assets (data + actions) ranked by sensitivity, which drives where you focus the rest of the audit.

### Tasks

- [ ] List all data the app holds: PII, payment data, auth credentials, customer data, internal data
- [ ] Rank each by sensitivity: critical (payment, password) / high (PII) / medium (analytics) / low (public)
- [ ] List the privileged actions: admin access, billing changes, data export, account deletion
- [ ] Brainstorm 3-5 attacker personas: external opportunist, malicious customer, insider with stolen creds, state actor (probably not relevant)
- [ ] For each asset, ask: how would each persona try to reach it? Note the top 3 attack paths

### Pointers

- **[Guide]** [STRIDE threat modeling](https://learn.microsoft.com/en-us/azure/security/develop/threat-modeling-tool-threats)
- **[Guide]** [OWASP threat modeling](https://owasp.org/www-community/Threat_Modeling)

> [!CAUTION]
> **Gotchas**
>
> - Threat models that try to enumerate every possible attack become un-actionable. Pick the top 5-10 paths and focus.
> - Most small SaaS attackers are opportunists running automated scanners, not nation-state APTs. Calibrate your defenses to the real threat, not the movie threat.

## Step 2: Audit authentication and session management

_Estimated time: Half a day_

OWASP Identification + Authentication Failures (A07). Most small SaaS apps fail one of: weak password rules, no rate limit on login, session tokens that don't expire, password reset tokens that don't expire, magic links that aren't single-use. Walk each.

### Tasks

- [ ] Check password policy: min 12 chars, no max, allow all printable, common-password block via Have I Been Pwned API or zxcvbn
- [ ] Check login rate limit: max 10 attempts per IP per minute, max 5 per username
- [ ] Check session token: HttpOnly + Secure + SameSite=Lax cookie, randomly generated >= 128 bits, expires after 30 days inactive
- [ ] Check password reset tokens: single-use, 60 min TTL, invalidated after use
- [ ] Check magic links: single-use, 15 min TTL, IP / device-bound if possible
- [ ] Check MFA support: TOTP at minimum, recommended for admin accounts
- [ ] Check account enumeration: 'login' and 'forgot password' should not reveal if email exists

### Pointers

- **[Official]** [OWASP A07: Authentication Failures](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/)
- **[Tool]** [Have I Been Pwned password API](https://haveibeenpwned.com/API/v3#PwnedPasswords)
- **[Code]** [zxcvbn password strength estimator](https://github.com/dropbox/zxcvbn)

> [!CAUTION]
> **Gotchas**
>
> - Magic links sent over email are no stronger than the email account's security. For admin accounts, require TOTP on top.
> - Account enumeration is a quiet bug: 'this email is not registered' on login = attacker can confirm a target customer is on your platform.

## Step 3: Audit access control: IDOR + missing authorization

_Estimated time: Half a day to a day_

OWASP Broken Access Control (A01) is the most common bug missed in solo audits. The pattern: an endpoint correctly authenticates the user but doesn't check that the user owns the resource being accessed. /api/orders/:id returns the order regardless of who's logged in. Walk every authenticated endpoint, ask: does the handler check the user owns this resource?

### Tasks

- [ ] List every endpoint that takes a resource ID in the URL or body (orders, accounts, files, etc.)
- [ ] For each, read the handler: is there an authorization check?
- [ ] Test by hand: log in as user A, request a resource ID belonging to user B, confirm 403 (not 200)
- [ ] Check 'object reference' patterns: file IDs, S3 URLs, attachment URLs - are they UUIDs or sequential ints?
- [ ] Review role-based checks: admin actions are gated by role check at the handler, not just the UI
- [ ] Run Semgrep with the OWASP Top 10 rule pack to catch common patterns programmatically

### Pointers

- **[Official]** [OWASP A01: Broken Access Control](https://owasp.org/Top10/A01_2021-Broken_Access_Control/)
- **[Guide]** [OWASP IDOR cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html)
- **[Tool]** [Semgrep OWASP Top 10 ruleset](https://semgrep.dev/p/owasp-top-ten)

> [!CAUTION]
> **Gotchas**
>
> - IDOR is the most common bug missed in solo audits. It's not flashy, but it's how customer data leaks happen.
> - ORM 'find by ID' calls without a tenant scope check are the #1 source of IDOR. Always scope queries by tenant ID + ID, never by ID alone.
> - Pre-signed S3 URLs that don't expire = file IDOR. Use short-lived signed URLs and verify ownership before issuing.

### Agent prompt for this step

```text
Read every authenticated endpoint in this codebase.

For each, output:
1. The route + handler file:line.
2. The resource being accessed (Order, User, Account, File, etc.).
3. The authorization check present in the handler (or "MISSING" if absent).
4. Risk rating: HIGH if multi-tenant data without check, MEDIUM if single-tenant without check, LOW if public-by-design.

Flag every "MISSING" as a likely IDOR finding. Output as proposed Findings rows, severity High by default for HIGH risk.
```

## Step 4: Audit injection: SQL, NoSQL, LDAP, command

_Estimated time: 2-3 hr_

OWASP Injection (A03). Almost always solved by parameterized queries and prepared statements. Walk the codebase for raw SQL strings, command exec calls, file path concatenation. ORMs are mostly safe but expose escape hatches that are dangerous.

### Tasks

- [ ] Grep for raw SQL: 'query(`SELECT', 'execute_raw', 'rawQuery', '$queryRaw' (Prisma)
- [ ] For each match: is the input parameterized? If a string-concat with user input, flag
- [ ] Grep for command exec: 'exec(', 'execSync(', 'spawn(', 'os.system(' - any user input flowing in is a P0 finding
- [ ] Grep for filesystem: path.join(userInput, ...) without validation = path traversal
- [ ] If you have NoSQL: check $where clauses and JS evaluation
- [ ] If you do server-side templating: check for SSTI in user-provided templates

### Pointers

- **[Official]** [OWASP A03: Injection](https://owasp.org/Top10/A03_2021-Injection/)
- **[Guide]** [OWASP injection prevention cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Injection_Prevention_Cheat_Sheet.html)

> [!CAUTION]
> **Gotchas**
>
> - Prisma's `$queryRaw` is the bypass for the 'ORMs are safe' rule. Audit every call; prefer `$queryRawUnsafe` only never.
> - Path traversal lives in file upload + download paths. `userInput.includes('..')` is not a sufficient check; use a library.
> - Server-side template injection (SSTI) is rare but devastating. If users can inject template syntax (Jinja, Handlebars, etc.), it's RCE.

## Step 5: Audit input validation, XSS, and CSRF

_Estimated time: Half a day_

Cross-site scripting and CSRF are old bugs that still ship. Modern frameworks default-escape (React, Vue auto-escape strings; Django auto-escapes templates) but every framework has bypass: dangerouslySetInnerHTML, raw template tags, server-rendered URLs from user data.

### Tasks

- [ ] Grep for XSS bypasses: 'dangerouslySetInnerHTML' (React), 'v-html' (Vue), '|safe' (Jinja/Django)
- [ ] For each: is the source user-controlled? Is it sanitized with DOMPurify or equivalent?
- [ ] Test reflected XSS: send `<script>alert(1)</script>` in every search box and form field
- [ ] Test stored XSS: paste it into every persisted field (profile name, comments, etc.) and view from another account
- [ ] Check CSRF: if you use cookies for auth, every state-changing endpoint should require an anti-CSRF token or SameSite=Lax/Strict cookie
- [ ] Check Content-Security-Policy header is set with at least 'default-src self' on top-level pages

### Pointers

- **[Guide]** [OWASP XSS prevention cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
- **[Code]** [DOMPurify (HTML sanitizer)](https://github.com/cure53/DOMPurify)
- **[Official]** [MDN: Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy)

> [!CAUTION]
> **Gotchas**
>
> - Markdown-rendered user content is a classic stored-XSS vector. Use a sanitizer that strips event handlers and javascript: URLs.
> - SVG file uploads can contain script tags. If you let users upload SVGs and re-serve them, sanitize before serving or serve from a different origin.

## Step 6: Audit cryptography: passwords, tokens, encryption at rest

_Estimated time: 2-3 hr_

OWASP Cryptographic Failures (A02). Common failures: passwords stored with MD5 or unsalted SHA, encryption with ECB mode, JWT with `none` algorithm allowed, secrets stored in env vars but logged.

### Tasks

- [ ] Verify password hashing: bcrypt cost 12, argon2id, or scrypt. NEVER MD5, SHA-1, or unsalted SHA-256
- [ ] Verify JWT validation: signature is checked, `alg` is restricted to a whitelist, expiration is enforced
- [ ] Verify TLS: HTTPS-only, HSTS header set with max-age >= 31536000
- [ ] Verify secrets are not logged (search for 'console.log' or 'logger.info' near `password`, `token`, `secret`, `api_key`)
- [ ] Verify encryption-at-rest for sensitive fields (tokens, secret keys, PII): KMS-backed envelope encryption
- [ ] Check random number generation: use `crypto.randomBytes` not `Math.random()` for any security-relevant value

### Pointers

- **[Official]** [OWASP A02: Cryptographic Failures](https://owasp.org/Top10/A02_2021-Cryptographic_Failures/)
- **[Guide]** [OWASP password storage cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
- **[Guide]** [OWASP JWT cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)

> [!CAUTION]
> **Gotchas**
>
> - JWT libraries that accept `alg: none` are still surprisingly common in old code. Always pin the algorithm whitelist explicitly.
> - Encrypting at rest with a key stored next to the data (in the same DB or env file) is theater. Use a KMS-backed key.
> - Math.random() is predictable enough that an attacker can guess sequence values. Always crypto.randomBytes or equivalent.

## Step 7: Audit dependencies: CVE scan + supply chain

_Estimated time: 1-2 hr to scan, varies to remediate_

OWASP Vulnerable and Outdated Components (A06). Most exploitable bugs in a modern app are in dependencies, not your own code. Run an SCA tool (Snyk, GitHub Dependabot, npm audit), prioritize by severity + reachability.

### Tasks

- [ ] Run `npm audit` (or `pnpm audit`, `cargo audit`, etc.) and review the High and Critical findings
- [ ] Run Snyk or GitHub Dependabot on the repo for ongoing monitoring
- [ ] For each Critical or High CVE, decide: patch, replace, or accept (with rationale)
- [ ] Check for unmaintained dependencies (last commit > 2 years, no recent releases) - these are a future-you risk
- [ ] Review dev dependencies separately - dev-only CVEs are lower priority but count for build-time supply chain
- [ ] Pin all dependency versions exactly in lockfile; no caret ranges in production lockfile

### Pointers

- **[Official]** [OWASP A06: Vulnerable Components](https://owasp.org/Top10/A06_2021-Vulnerable_and_Outdated_Components/)
- **[Official]** [GitHub Dependabot](https://docs.github.com/en/code-security/dependabot)
- **[Tool]** [Snyk vulnerability DB](https://security.snyk.io/)

> [!CAUTION]
> **Gotchas**
>
> - `npm audit` reports thousands of vulns in dev dependencies (webpack, etc.) that don't reach production. Focus on production deps + actual reachability before you panic.
> - Lockfile with caret ranges (`^1.2.3`) means the same install on Tuesday gives different versions than Friday. Pin exact versions for reproducibility.

## Step 8: Audit secrets and credentials hygiene

_Estimated time: Half a day_

Secrets committed to git history are the most common data breach root cause for small SaaS. Run trufflehog over the entire git history (not just HEAD); rotate any secret that ever touched a public repo. Move secrets out of env files in repos and into a secret manager.

### Tasks

- [ ] Run trufflehog on every repo: `trufflehog git file://. --since-commit HEAD` and again over full history
- [ ] For every found secret: rotate it immediately, even if it's already invalidated
- [ ] Check `.env.example` files don't contain real values
- [ ] Verify CI secrets are stored in the platform's secret store (GitHub Actions secrets, Vercel env vars, etc.), not in the repo
- [ ] Verify deploy scripts read secrets from a secret manager (AWS Secrets Manager, HashiCorp Vault, Doppler), not from baked images or env files
- [ ] Audit who has production secret access; remove anyone who left the team

### Pointers

- **[Code]** [trufflehog GitHub repo](https://github.com/trufflesecurity/trufflehog)
- **[Code]** [git-secrets pre-commit hook](https://github.com/awslabs/git-secrets)
- **[Tool]** [Doppler secret manager](https://www.doppler.com/)

> [!CAUTION]
> **Gotchas**
>
> - A secret committed to a public repo is compromised the moment it pushes; rotate within minutes, not hours. GitHub's secret scanning will alert known providers (AWS, Stripe, etc.) automatically.
> - Force-removing a secret from git history doesn't help if anyone cloned the repo before. Always rotate, don't just rewrite.

## Step 9: Audit logging, monitoring, and incident response

_Estimated time: 2-3 hr_

OWASP Security Logging and Monitoring Failures (A09). If you can't see an attack while it happens, you can't respond. Verify auth events are logged, log retention is at least 90 days, alerts fire on suspicious patterns, and the team has a written incident response runbook.

### Tasks

- [ ] Verify auth events are logged: login success, login fail, password change, MFA enroll, role change, account deletion
- [ ] Verify logs are retained at least 90 days (longer for compliance industries)
- [ ] Verify logs cannot be deleted by application code (write-only access from the app)
- [ ] Set up alerts: 10+ failed logins from one IP, 5+ password resets for one account in an hour, role escalation outside admin paths
- [ ] Write a 1-page incident response runbook: who to call, how to revoke sessions, how to rotate credentials, how to communicate with customers
- [ ] Run a tabletop exercise: 'attacker has admin access, what do you do in the first 30 minutes?'

### Pointers

- **[Official]** [OWASP A09: Logging Failures](https://owasp.org/Top10/A09_2021-Security_Logging_and_Monitoring_Failures/)
- **[Guide]** [NIST incident response guide](https://csrc.nist.gov/publications/detail/sp/800-61/rev-2/final)

> [!CAUTION]
> **Gotchas**
>
> - Logging the failed-login email + password as plaintext is a foot-gun. Log the email; never log the password attempt, even on failure.
> - Most small teams don't have a written incident response runbook. The first 30 minutes of a breach are when most damage is preventable; have the runbook.

## Step 10: Write the findings report and remediation plan

_Estimated time: Half a day_

Final step. Compile every finding into a single report with severity ratings (Critical / High / Medium / Low / Info), evidence, remediation, and an owner with a due date. Critical and High get fixed in days; Medium in weeks; Low in the next quarter.

### Tasks

- [ ] List every finding with: title, OWASP category, severity, evidence (screenshots / curl examples), remediation, owner, due date
- [ ] Write the executive summary: 1-2 paragraphs on overall posture + the top 3 risks
- [ ] Schedule the remediation work in your tracker; Critical due within 1 week, High within 30 days
- [ ] Schedule the next audit in 90 days
- [ ] If you're SOC 2 / ISO 27001 / HIPAA-bound: file the audit + remediations in your compliance evidence repo

### Pointers

- **[Tool]** [CVSS calculator (severity scoring)](https://www.first.org/cvss/calculator/3.1)
- **[Guide]** [OWASP risk rating methodology](https://owasp.org/www-community/OWASP_Risk_Rating_Methodology)

> [!CAUTION]
> **Gotchas**
>
> - Severity inflation makes the report unactionable. Calibrate Critical strictly (immediate customer-data loss risk); use Medium liberally.
> - An audit without owners and due dates becomes shelf-ware. Every finding needs a single owner and a real date.

### Agent prompt for this step

```text
Compile the audit Findings rows into a final report.

Read every row in the Findings surface. Output:
1. Executive summary (2 paragraphs): overall posture + top 3 risks.
2. Findings table grouped by severity (Critical / High / Medium / Low / Info).
3. For each finding: title, OWASP category, evidence, remediation, owner, due date.
4. Remediation timeline: which Critical / High items must complete in 1 week, 30 days.
5. Next audit due date (90 days from today).

Constraints: no actual credentials, secrets, or sensitive PII in the output - reference, don't reproduce.

Output as the Brief surface, replacing prior content if any.
```

---

## Hand the template to your agent

Paste the prompt below into your agent's permanent system prompt so the agent reads, writes, and maintains this workspace as you work through the steps.

```text
You are an agent on the "Run a security audit" playbook workspace.

Your role: maintain the four surfaces (Steps, Pointers, Findings, Brief) as the auditor works through the checklist.

Cadence:
- When the auditor flags a finding, append a row to Findings: title, OWASP category, severity (Critical/High/Medium/Low/Info), evidence link, owner, due date.
- After each step, append a section to the Brief summarizing what was checked, what was found, and what was clean.
- For each Critical or High finding, propose a 1-line remediation ticket the user can paste into their tracker.

First MCP tool calls:
1. list_surfaces(workspace_slug="run-a-security-audit-of-a-saas")
2. list_rows(workspace_slug="run-a-security-audit-of-a-saas", surface_slug="findings")
3. get_doc(workspace_slug="run-a-security-audit-of-a-saas", surface_slug="brief")

Treat all findings as confidential - never include credentials, tokens, or sensitive PII in any row.
```

---

## FAQ

### Is this a real security audit or just a checklist?

It's the 80/20: the categories that catch most real bugs in small SaaS apps. A full pen test from a security firm is more thorough (manual exploitation, custom rules, deep web app testing) and costs $20-50k. This playbook gets you to 'no obvious issues' for 1 week of effort and free tooling. For SOC 2 / ISO 27001 compliance, you still need the third-party audit; this is the prep work that makes that audit cheaper and faster.

### What's the most common bug small SaaS apps actually have?

IDOR. By a large margin. The pattern: an endpoint correctly checks the user is logged in, but doesn't check the user owns the resource being accessed. /api/orders/12345 returns the order regardless of who's logged in. Test by hand: log in as user A, request a resource ID belonging to user B, see what happens. If you get the data, you have IDOR. Fix at the data-access layer (always scope queries by tenant + ID).

### Should I run this audit before or after my SOC 2?

Before, by 2-3 months. SOC 2 evidence is mostly process (you have a logging policy, you have an incident response plan); it doesn't deeply audit the code. Running this audit before SOC 2 means you fix the actual bugs first, then the auditor checks that the process exists. Cheaper than fixing bugs after the auditor flags them.

### How often should I re-run this audit?

Quarterly is the right cadence for an actively developed SaaS. The first audit takes a week; re-audits take 1-2 days because you only re-check the categories that touched changed code. Critical and High findings should be fixed within their due dates; Medium findings get re-verified at the next audit. Tools (Snyk, Dependabot, Sentry) cover the gap between audits.

### Can my AI agents help run the audit?

Yes, especially for the code-reading-heavy steps. Agents are useful for: scanning every authenticated endpoint for missing authorization checks, scanning for raw SQL and command exec patterns, drafting the findings report from raw notes, and summarising the remediation plan into ticket descriptions. The judgement calls (severity rating, threat model, remediation prioritization) need humans. The playbook ships agent prompts inline for the access-control and findings-compilation steps.

