Last year, at a previous company, I asked a summer intern - two years into university - to set up OWASP ZAP scans against our app. From the moment I asked to the moment the first scan actually ran was about two weeks. He did good work: got ZAP running as a scheduled action in AWS, wired up against the live app, scanning on a cadence. Two weeks for that, at his level, is great. Standing up the environment, wiring auth, defining contexts, iterating until the scans were stable - none of it is hard, all of it is tedious, and together it's a sprint.
This week I set it up for Yovico in about an hour, and most of that hour was waiting for active scans to finish.
The difference isn't that ZAP got faster. Two things changed.
The first is the front end. Setting up a DAST run means knowing your API surface - what endpoints exist, which need auth, which have side effects you don't want fuzzed. Unless you've been OpenAPI-compliant from day one (who actually is?), that's a manual exercise. Read the route files. Guess at auth. Build the exclusion list. Claude Code knows the code because it just read it. It knows what's actually on the wire via the DevTools MCP. And it can drive ZAP through the ZAP MCP. Three sources of truth, one agent stitching them together. The 8-zone scan plan below came out of main.go in a single pass.
The second is the back end. Filing the issues, updating the internal docs, updating the public security attestation - that work has always existed, and at a small startup it's what determines whether a scan is a one-off curiosity or part of an actual security process. Now it lives in the same conversation as the scan itself.
Here's exactly how it went.
The stack
mcp-zap-server is a Java/Spring Boot MCP server that wraps ZAP's REST API. It runs alongside ZAP in Docker Compose and exposes around 20 tools that any MCP-capable client can call:
- Context management:
create_context,include_in_context,exclude_from_context- define what's in scope - Authentication:
set_form_based_auth,set_logged_in_indicator,set_logged_out_indicator- tell ZAP how to log in and how to detect session expiry - Spider:
start_spider_scan,get_spider_scan_status,get_spider_results- crawl the API surface - Active scan:
start_active_scan,get_active_scan_status,get_active_scan_progress- fuzz each endpoint - Results:
get_alerts,get_alert_summary,generate_report- pull findings
Registering the server in Claude Code is one command:
claude mcp add \
--transport http \
--header "X-API-Key: <your-mcp-api-key>" \
--scope user \
zap http://localhost:7456/mcp
One Linux-specific detail: host.docker.internal isn't automatically resolved the way it is on macOS. Rather than fighting DNS, we connected the ZAP container directly to the app's Docker network. Now ZAP reaches the engine by container name, no extra host mapping required.
What I actually typed
The full input that initiated the entire security session was:
"I want to set up an OWASP ZAP run, using https://github.com/dtkmn/mcp-zap-server."
That's it. Claude Code took it from there.
It read the main router file to extract all 80+ API routes across 14 route groups. It identified which endpoints needed auth, which had side effects that should be excluded from active scanning (OAuth redirects, Stripe checkout, webhook receivers), and what the auth mechanism was (a short-lived JWT cookie with a separately scoped refresh token). From that source reading, Claude wrote a complete 8-zone scan plan to disk:
- Public surface - SQLi, enumeration, token predictability
- Auth session - CSRF, logout invalidation, JWT lifecycle
- File upload - path traversal, MIME confusion, size limits, SVG XSS
- SSRF - outbound-fetch endpoints
- IDOR - cross-tenant resource access with two accounts
- Admin auth - header injection, regular-user JWT on admin routes
- Billing - promo brute-force, plan limit bypass, timing
- Security headers - CSP, HSTS, X-Content-Type-Options
Each zone came with specific endpoints, payloads, expected responses, and the ZAP context setup commands. Claude also wrote the docker-compose.override.yml to connect ZAP to the app network.
One practical note on the MCP session boundary: MCP servers registered via claude mcp add are available from the next session, not the current one. For this run, Claude drove ZAP's REST API directly with curl and the API key - the MCP tools are the cleaner path for future runs now that the server is registered.
How the scanning actually worked
For each zone, Claude issued targeted requests and interpreted the results in context. A few examples that show why source-reading matters more than a generic spider:
SSRF (Zone 4). The obvious attack surface is a harvest endpoint that takes a URL in the request body. That endpoint has validation. But reading the surrounding handlers revealed a second path: a PATCH that stores a URL on a resource, and a separate trigger that later passes the stored URL straight into headless Chrome - no scheme allowlist, no RFC-1918 check. A scanner hitting the obvious endpoint would miss this entirely. The real attack is two-step: write, then trigger. That's now tracked as a critical finding.
Timing (Zone 1). Reading the login handler showed that the bcrypt comparison only runs when a user is found. Unknown emails return early, creating a measurable response time difference - 7ms versus 58ms. This doesn't show up in a passive scan. It shows up because Claude read the conditional branch before writing the test.
IDOR (Zone 5). For this one, Claude used ZAP's context-aware tooling. After setting up a second test account via create_context + set_form_based_auth with the second user's credentials, it fired all 10 cross-tenant endpoint combinations. All returned 403 or 404. Clean.
What gets produced without me doing anything
After the scans ran, Claude:
- Filed GitHub issues for each finding - with reproduction steps, expected vs actual, and suggested fixes - directly via
gh issue create - Updated the internal
known-issues.mdwith status, date, and notes on each finding - Updated the public CAIQ-Lite security attestation on the website - upgrading the TVM-03.1 (Vulnerability Identification) control to cite OWASP ZAP DAST as a second detection layer alongside Syft + Grype, and strengthening the IDOR and admin auth isolation claims with references to the specific zones that confirmed them
- Wrote a run stamp capturing the commit hash, zones completed, and verdict
My other two inputs during the entire session: "perhaps we should put them on the same network?" (the Docker networking fix), and "I suppose we can improve our CAIQ-Lite attestation." Both resulted in immediate action.
Why the attestation update matters
The third item in that list is the one that quietly does the most work, so it's worth dwelling on.
Before this session, our vulnerability identification rested on one layer: Syft generating an SBOM, Grype matching it against the CVE database. That's component-level scanning. It tells you when a dependency has a known vulnerability - when log4j has a CVE, when a Go module needs to be bumped. It does not tell you whether your own code has a runtime-exploitable issue.
DAST is the complementary layer. It tests the running system from the outside, finding behaviors that only manifest when requests actually hit the application. The stored-URL SSRF is the canonical example: dependency scanning can't see it (no vulnerable component), and even static analysis would struggle (the dataflow is split across two requests and persisted state). It exists only at runtime. You catch it by probing the running app, or you don't catch it.
So the attestation moved from one detection layer to two, and the two are genuinely complementary rather than overlapping.
The CAIQ-Lite update isn't a documentation chore. A security posture that exists only in your head, or in a private known-issues file, is operationally real but commercially invisible. The same posture documented in a public attestation - naming specific controls, citing specific detection mechanisms, referencing the zones that confirmed each isolation claim - is something a buyer's procurement team can put in their vendor risk file. That's the transition from "trust us" to "here's the evidence." For a two-person company that sells to anyone with a vendor-risk process, that transition is the deal.
And now it's a slash command
The scan ran. The issues were filed. The attestation was updated. At that point I asked the obvious follow-up: do we need an agent or an SDK or something to run this again next month?
We didn't. The MCP tools were already registered. The scan plan was already on disk. The only missing piece was a reusable entry point.
Claude Code supports slash commands as markdown files in .claude/commands/. So Claude wrote .claude/commands/security-scan.md - an instruction set codifying the entire session: the zone order, the exclusion list, the auth setup, the Docker networking step, the issue-filing convention, the known-issues update format, the run stamp.
Now /security-scan from any session in the repo kicks off the full thing. All 8 zones. Issues filed. Docs updated. Attestation refreshed if findings change. One line.
That's the real shape of the workflow now. The hour I spent this week wasn't the cost of running a scan. It was the cost of writing the recipe for every future scan.
What we found
Across 8 zones on Yovico's Go + React engine:
| Finding | Zone | Severity | |---|---|---| | SSRF via stored URL → headless Chrome | 4 | Critical | | Login timing side-channel (bcrypt skipped for unknown users) | 1 | Medium | | Share-token 500 on non-UUID input (Postgres cast failure) | 1 | Medium | | JWT not server-side invalidated on logout (15-min window) | 2 | Medium | | No rate limiting on promo-code redemption | 7 | Medium |
Clean: IDOR across all tenant-scoped endpoints, admin endpoint isolation under header-injection bypass, Stripe webhook signature enforcement, file upload path traversal, avatar MIME validation, file size cap.
The actual value
The intern wasn't slow, and I want to be fair to him: he was a second-year university student doing a summer internship, and he did great work. I was happy with him. He set up ZAP as a scheduled action in AWS, against the running app, and got us scanning. The work wasn't hard - it was tedious. Standing up the environment, wiring auth, defining contexts, iterating until the scans were stable.
Apples to apples, what I did this week is the same thing: I now have ZAP running against Yovico, scanning on a cadence. The hour-versus-two-weeks comparison is real, but it's a comparison of the setup, not the scanning itself.
What's not apples to apples - what the intern didn't do, and what nobody was going to do at a two-person company - is the work on the other side of the scan. For Yovico, this changes three things at once. The scans run on a cadence, which they wouldn't have. The findings get filed, tracked, and fixed, which they wouldn't have been. And the public security attestation reflects the actual control framework, which it couldn't have. The first is a productivity story. The second is a process story. The third is the one that lets a two-person company credibly sell to a buyer with a procurement department.
This is what The Unbureaucratic Company looks like in one specific domain.
