June 24, 2026
A Claude Code Workflow for the OWASP Top 10
I shipped a Firestore rules change at 11 PM, ran my security-reviewer subagent against the diff, and it caught a request.auth.uidโฆ

By Zac Smith
7 min read
I shipped a Firestore rules change at 11 PM, ran my security-reviewer subagent against the diff, and it caught a request.auth.uid comparison that would have let any signed-in user read any other user's document. The fix was three lines. The detection was free, in the sense that the workflow was already in place from a previous sprint.
This is the workflow. It is opinionated, scoped to my stack (Vite + React JS, Firebase, Playwright, Vitest, Claude Code on Opus 4.8), and built around the OWASP Top 10 (2025) as the structuring methodology. I am not claiming it generalizes. I am claiming it works for me, and that the architectural choices โ MCP server first, subagents per category, structured FINAL REPORT output โ are portable even if the categories or the host change.
Why OWASP Top 10 as the spine
The OWASP Top 10 is a flat list of ten categories. That maps cleanly onto ten focused subagent prompts or ten checklist passes. It is also stable enough that the prompts do not rot every quarter. Compare to chasing CVE feeds or framework-specific advisories: useful, but not a backbone.
The 2021 categories I wire prompts against:
A01Broken Access ControlA02Cryptographic FailuresA03InjectionA04Insecure DesignA05Security MisconfigurationA06Vulnerable and Outdated ComponentsA07Identification and Authentication FailuresA08Software and Data Integrity FailuresA09Security Logging and Monitoring FailuresA10Server-Side Request Forgery
Not every category is equally relevant to a Firebase-backed SPA. A10 SSRF is mostly a non-issue when you have no server-side fetch surface. A01 Broken Access Control, on the other hand, is essentially "did you write your firestore.rules correctly," which is where most of my real bugs live.
Architecture overview
The layout, in the order I built it:
- MCP server (
owasp-recon) โ does the actual evidence gathering: readsfirestore.rules, runsnpm audit --json, greps for known-bad patterns, fetches dependency metadata. Pure Node, no editor coupling. - Subagents (
.claude/agents/*.md) โ one per OWASP category that is actually relevant to my stack. Each calls the MCP server, applies category-specific reasoning, and returns a structuredFINAL REPORT. - Slash command (
/owasp-pass) โ orchestrates the relevant subagents from the parent Opus agent and assembles the consolidated report. - PreToolUse hook โ blocks the workflow from touching production Firebase by checking
FIREBASE_PROJECTagainst a deny list.
This is the Tier 1 / Tier 2 / Tier 3 split I always use: ~80% of the code lives in the MCP server, ~15% in the Claude plugin manifest and subagent prompts, ~5% (zero in this case) in anything editor-specific.
The MCP server: owasp-recon
The server exposes a small, blunt set of tools. The model is good at reasoning; it is bad at remembering to pass --json to npm audit. So I encode the boring parts:
read_firestore_rulesโ returns the contents offirestore.rulesplus a parsed AST ofmatchblocks.list_firestore_indexesโ readsfirestore.indexes.json.npm_audit_jsonโ runsnpm audit --jsonand returns parsed output. Shells out via the persistent Bash session.grep_secretsโ runs a curated set of regexes (AIza[0-9A-Za-z\-_]{35}for Firebase API keys exposed in non-client paths,-----BEGIN [A-Z ]+ PRIVATE KEY-----, AWS access key patterns, etc.) and returns hits with file + line.find_admin_sdk_in_clientโ greps forfirebase-adminimports in any path undersrc/(which is client-only by convention).list_env_filesโ returns presence/absence of.env*files and whether they are gitignored.fetch_dep_metadataโ given a package name, returns published date, last update, weekly downloads, maintainer count. Used forA08integrity checks.
The server is ~600 lines of plain JavaScript. It runs unchanged in Claude Desktop, Claude Code, and a custom SDK agent I use for batch scans. That portability is the entire point of writing it as MCP first.
Known costs I accept: stdio framing has bitten me once on long npm audit outputs (the fix was chunking responses under the 25,000-token silent truncation cap on the Claude Code side). Auth is non-existent because the server only reads local files and shells out locally โ if I move it remote, I will pay that bill then.
Subagent definitions
One file per relevant OWASP category at .claude/agents/owasp-a01.md through .claude/agents/owasp-a08.md. I skip A10 entirely and fold A09 into the parent agent's responsibility because my logging story is centralized.
Frontmatter template:
---
name: owasp-a01-access-control
description: Reviews Firestore rules and route guards for broken access control. Read-only.
tools: [Read, Grep, Glob, mcp__owasp-recon__read_firestore_rules, mcp__owasp-recon__list_firestore_indexes]
model: claude-opus-4-8
------
name: owasp-a01-access-control
description: Reviews Firestore rules and route guards for broken access control. Read-only.
tools: [Read, Grep, Glob, mcp__owasp-recon__read_firestore_rules, mcp__owasp-recon__list_firestore_indexes]
model: claude-opus-4-8
---A01 runs on Opus because access control reasoning is where hallucination costs the most. A06 (vulnerable components) and A09-adjacent log scans run on Haiku โ the work is mostly pattern matching against npm audit JSON, and Haiku is fine.
The body of each subagent prompt follows the same five-section shape:
- Role โ one sentence. "You are a security reviewer focused on OWASP A01: Broken Access Control."
- Evidence to gather โ explicit list of MCP tool calls to make before reasoning.
- Category-specific checks โ the actual checklist (see below).
- Output contract โ the
FINAL REPORTschema. - Do not โ explicit negative list. "Do not modify files. Do not run Bash. Do not speculate beyond evidence returned by tools."
Example: A01 Broken Access Control checks
The meat of owasp-a01-access-control.md:
- For every
match /{collection}/{docId}block infirestore.rules, verify there is an explicitallow readandallow writerule. Implicit deny is correct but flag for documentation. - Flag any rule that uses
request.auth != nullwithout also checkingrequest.auth.uidagainst a document field. - Flag any
allow write: if trueorallow read: if true. No exceptions. - Flag any rule referencing
resource.datawithout a correspondingrequest.resource.datacheck on write paths. - Cross-reference
firestore.indexes.json: any composite index on a field used as an access-control discriminator (e.g.,ownerId) is expected; flag if missing. - Grep
src/for any direct Firestore SDK calls bypassingsrc/lib/db.js(per the project's centralization convention).
The subagent never writes anything. It returns a report. The parent agent decides what to do with it.
The FINAL REPORT contract
Every subagent ends with this exact structure. I mandate it because the parent only sees the subagent's final message โ intermediate reasoning is discarded โ and without a contract the parent will confabulate.
FINAL REPORT
============
Category: A01 Broken Access Control
Scope: firestore.rules (47 lines), src/lib/db.js, 12 files under src/
Evidence calls made:
- mcp__owasp-recon__read_firestore_rules: 1
- Grep on src/: 3
Findings:
[HIGH] firestore.rules:23 โ match /users/{uid} allows read if request.auth != null; missing uid equality check.
[MED] firestore.rules:31 โ allow write on /posts/{id} checks request.auth.uid against resource.data.authorId but not request.resource.data.authorId, permitting authorId rewrite.
[LOW] src/components/AdminPanel.jsx:14 โ direct firestore import bypasses src/lib/db.js convention.
Unchecked:
- Storage rules (firebase.storage.rules not present in repo).
Confidence: high on rules findings, medium on src/ grep (may have missed dynamic imports).FINAL REPORT
============
Category: A01 Broken Access Control
Scope: firestore.rules (47 lines), src/lib/db.js, 12 files under src/
Evidence calls made:
- mcp__owasp-recon__read_firestore_rules: 1
- Grep on src/: 3
Findings:
[HIGH] firestore.rules:23 โ match /users/{uid} allows read if request.auth != null; missing uid equality check.
[MED] firestore.rules:31 โ allow write on /posts/{id} checks request.auth.uid against resource.data.authorId but not request.resource.data.authorId, permitting authorId rewrite.
[LOW] src/components/AdminPanel.jsx:14 โ direct firestore import bypasses src/lib/db.js convention.
Unchecked:
- Storage rules (firebase.storage.rules not present in repo).
Confidence: high on rules findings, medium on src/ grep (may have missed dynamic imports).Severity tags, line numbers, an explicit Unchecked section, and a confidence line. The Unchecked section is the one I add that most templates skip โ it is the difference between "clean pass" and "I did not look there."
Orchestration: the /owasp-pass command
Claude plugin manifest (~40 lines) registers a slash command. The command body is a prompt to the parent Opus agent:
Run a full OWASP pass. For each of A01, A02, A03, A05, A06, A07, A08, spawn the corresponding subagent via the Task tool. Run A01, A02, A07 sequentially (they may inform each other). Run A03, A05, A06, A08 in parallel.
After all subagents return, produce a CONSOLIDATED REPORT grouped by severity (HIGH/MED/LOW), with per-finding remediation suggestions. Do not modify any files. Do not commit.Run a full OWASP pass. For each of A01, A02, A03, A05, A06, A07, A08, spawn the corresponding subagent via the Task tool. Run A01, A02, A07 sequentially (they may inform each other). Run A03, A05, A06, A08 in parallel.
After all subagents return, produce a CONSOLIDATED REPORT grouped by severity (HIGH/MED/LOW), with per-finding remediation suggestions. Do not modify any files. Do not commit.The parent gets back seven structured reports and assembles a single document. Because the subagents never see each other's context, there is no cross-contamination โ each one's confidence rating is independent.
Sequential vs. parallel matters: A01 access control findings often imply A07 auth findings, and I want the A07 subagent to be informed by A01's report. I pass it in via the Task prompt explicitly, since subagents do not share context.
The PreToolUse hook that saved me once
In .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "jq -e 'if .tool_input.command | test(\"firebase.*--project[= ]myapp-prod\") then halt_error(\"blocked: prod project\") else . end'"
}
]
},
"env": {
"FIREBASE_PROJECT": "myapp-dev",
"NODE_ENV": "test"
}
}{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "jq -e 'if .tool_input.command | test(\"firebase.*--project[= ]myapp-prod\") then halt_error(\"blocked: prod project\") else . end'"
}
]
},
"env": {
"FIREBASE_PROJECT": "myapp-dev",
"NODE_ENV": "test"
}
}The env key pins the project for every Bash invocation in the session. The PreToolUse hook is belt-and-suspenders: even if the model constructs a firebase CLI call with --project myapp-prod explicitly, the hook halts it before permission check. Deny-on-conflict semantics apply (deny wins), so this is the right place for it.
This hook fired exactly once in six months. That was enough.
Config that makes the workflow actually work
The defaults will burn tokens silently. My active settings for any project running this workflow:
CLAUDE_CODE_MAX_OUTPUT_TOKENS=16384. The consolidated report from seven subagents will not fit in 8192, and truncation causes the parent to re-spawn subagents it thinks failed.DISABLE_AUTOUPDATER=1during the sprint. TheTasktool description changed meaningfully between 1.x and 2.x; pinning means the prompts I wrote against 2.0.3 keep behaving the same.MAX_THINKING_TOKENSraised for the parent onA04Insecure Design passes specifically โ that category is the one where extended thinking earns its keep.CLAUDE.mdstays at ~40 lines. Adding the OWASP workflow did not extend it; the workflow lives in subagent files and the slash command, not in the always-injected context.
What this does not do
Naming the negative, because it is the most useful section for anyone considering this:
- Does not replace a real pentest. It catches static and configuration issues. It does not exercise the running app for auth bypass, race conditions, or business-logic flaws.
- Does not cover
A10SSRF in any depth. My app has no server-side fetch surface worth scanning. If yours does, you need a different subagent. - Does not run continuously. I trigger
/owasp-passbefore merges tomainand before anyfirestore.rulesdeploy. Wiring it into CI is on the list; I have not done it. - Does not sign or verify the MCP server itself. There is no supply-chain story for Claude plugins as of observation date. The server is in my repo, I read every line, that is the entire trust model.
- Does not catch zero-days in dependencies.
npm auditlags. Pair it withfetch_dep_metadataheuristics (new package, single maintainer, recent ownership change) and accept that this is still incomplete.
Takeaway
The OWASP Top 10 is a structuring device, not a magic checklist. What makes it work as a Claude workflow is the same thing that makes any agent workflow work: put the logic in an MCP server so it survives host changes, split the problem into focused subagents with restricted tool sets, mandate structured output so the parent does not hallucinate, and pin the configuration so model behavior does not drift mid-sprint.
The rewrite cost if Claude Code's plugin format changes tomorrow: ~20 lines of manifest. The rewrite cost if I had built this as a Cursor plugin with inline diff annotations: most of the 600-line MCP server, because the editor coupling would have been load-bearing throughout. That is the bet, and it is the same bet every time.
If you build one of these, start with A01 only. Get the FINAL REPORT contract right on a single category before fanning out. The orchestration is the easy part; the output contract is where the tokens live.