OWASP ZAP Testing Guide: API, Authenticated Scans & CI/CD Integration
OWASP ZAP is the benchmark for open-source web application security scanning. Most teams use it for baseline scans — but ZAP's real power is in its API, authenticated scanning, and deep CI/CD integration. This guide covers the complete picture.
Active vs Passive Scanning
Understanding the difference determines where and when you run each mode.
Passive scanning observes traffic without sending additional requests. ZAP analyzes HTTP requests and responses flowing through it and flags problems it sees: missing security headers, insecure cookie flags, information disclosure in responses, deprecated TLS versions. Zero risk to the application — it can run against production.
Active scanning attacks the application. ZAP sends crafted payloads — SQL injection strings, XSS probes, path traversal sequences — and analyzes how the application responds. It will find vulnerabilities passive scanning cannot detect: reflected XSS, SQL injection, command injection, SSRF. Never run active scanning against production; it can corrupt data and cause downtime.
| Mode | What It Finds | Safe for Production | Time |
|---|---|---|---|
| Passive | Headers, cookies, TLS, info disclosure | Yes | Minutes |
| Active | Injection, XSS, traversal, SSRF | No — staging only | 10-60 min |
ZAP API: Programmatic Control
The ZAP API enables scripted control of every scanning function. Start ZAP in daemon mode:
docker run -u zap -p 8080:8080 zaproxy/zap-stable \
zap.sh -daemon -port 8080 -host 0.0.0.0 \
-config api.disablekey=truePython API Client
pip install python-owasp-zap-v2.4from zapv2 import ZAPv2
zap = ZAPv2(proxies={'http': 'http://localhost:8080', 'https': 'http://localhost:8080'})
target = 'https://staging.example.com'
# Spider to discover content
print('Spidering target...')
scan_id = zap.spider.scan(target)
zap.spider.wait_for_complete(scan_id)
print(f'Spider found {len(zap.spider.results(scan_id))} URLs')
# Wait for passive scan queue to clear
zap.pscan.wait_for_complete()
# Active scan
print('Starting active scan...')
scan_id = zap.ascan.scan(target)
while int(zap.ascan.status(scan_id)) < 100:
print(f'Active scan: {zap.ascan.status(scan_id)}%')
import time; time.sleep(5)
# Get results
alerts = zap.core.alerts(baseurl=target)
high_alerts = [a for a in alerts if a['risk'] == 'High']
print(f'High-risk findings: {len(high_alerts)}')
# Generate report
with open('zap-report.html', 'w') as f:
f.write(zap.core.htmlreport())REST API Direct
ZAP exposes a REST API at http://localhost:8080/JSON/:
# Start spider
curl <span class="hljs-string">"http://localhost:8080/JSON/spider/action/scan/?url=https://staging.example.com"
<span class="hljs-comment"># Check status
curl <span class="hljs-string">"http://localhost:8080/JSON/spider/view/status/?scanId=0"
<span class="hljs-comment"># Start active scan
curl <span class="hljs-string">"http://localhost:8080/JSON/ascan/action/scan/?url=https://staging.example.com"
<span class="hljs-comment"># Get alerts
curl <span class="hljs-string">"http://localhost:8080/JSON/core/view/alerts/?baseurl=https://staging.example.com" <span class="hljs-pipe">| jq <span class="hljs-string">'.alerts[] | select(.risk=="High")'Authenticated Scanning
Unauthenticated scans miss most of the attack surface. ZAP supports multiple authentication methods.
Form-Based Authentication
Create auth.yaml:
env:
contexts:
- name: myapp
urls:
- https://staging.example.com
includePaths:
- https://staging.example.com.*
excludePaths:
- https://staging.example.com/logout.*
authentication:
method: form
parameters:
loginUrl: https://staging.example.com/auth/login
loginRequestData: email={%username%}&password={%password%}
loggedInRegex: \QDashboard\E
loggedOutRegex: \QSign in\E
sessionManagement:
method: cookie
users:
- name: testuser
credentials:
username: test@example.com
password: TestPassword123
jobs:
- type: spider
parameters:
context: myapp
user: testuser
maxDuration: 5
- type: passiveScan-wait
- type: activeScan
parameters:
context: myapp
user: testuser
- type: report
parameters:
template: traditional-html
reportDir: /zap/wrk/
reportFile: zap-report.htmlRun it:
docker run --rm \
-v $(<span class="hljs-built_in">pwd):/zap/wrk:rw \
zaproxy/zap-stable \
zap.sh -cmd -autorun /zap/wrk/auth.yamlToken-Based Authentication (JWT/Bearer)
For APIs using JWT:
authentication:
method: script
parameters:
script: /zap/wrk/auth-script.js
scriptEngine: Oracle Nashorn
sessionManagement:
method: script
parameters:
script: /zap/wrk/session-script.jsauth-script.js:
function authenticate(helper, paramsValues, credentials) {
var loginUrl = paramsValues.get('Login URL');
var postBody = 'username=' + credentials.getParam('Username') +
'&password=' + credentials.getParam('Password');
var msg = helper.prepareMessage();
msg.getRequestHeader().setMethod('POST');
msg.getRequestHeader().setURI(new java.net.URI(loginUrl, true));
msg.getRequestHeader().setHeader('Content-Type', 'application/json');
msg.setRequestBody(JSON.stringify({
username: credentials.getParam('Username'),
password: credentials.getParam('Password')
}));
helper.sendAndReceive(msg);
return msg;
}
function getRequiredParamsNames() { return ['Login URL']; }
function getOptionalParamsNames() { return []; }
function getCredentialsParamsNames() { return ['Username', 'Password']; }Header Injection for API Keys
The simplest approach for API key auth:
docker run --rm zaproxy/zap-stable zap-api-scan.py \
-t https://api.staging.example.com/openapi.json \
-f openapi \
-H <span class="hljs-string">"X-API-Key: your-test-api-key" \
-r zap-api-report.htmlCI/CD Integration
GitHub Actions — Full Authenticated Scan
name: Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
security-scan:
runs-on: ubuntu-latest
services:
app:
image: your-registry/your-app:latest
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
ports:
- 3000:3000
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Wait for app to be ready
run: |
timeout 60 bash -c 'until curl -f http://localhost:3000/health; do sleep 2; done'
- name: Create test user
run: |
curl -X POST http://localhost:3000/api/setup-test-user \
-H "Content-Type: application/json" \
-d '{"email":"zap@test.com","password":"ZapTest123!"}'
- name: Run ZAP baseline scan
uses: zaproxy/action-baseline@v0.12.0
with:
target: 'http://localhost:3000'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a'
- name: Run ZAP full scan
if: github.ref == 'refs/heads/main'
uses: zaproxy/action-full-scan@v0.10.0
with:
target: 'http://localhost:3000'
rules_file_name: '.zap/rules.tsv'
- name: Upload ZAP reports
uses: actions/upload-artifact@v4
if: always()
with:
name: zap-security-reports
path: |
report_html.html
report_json.json.zap/rules.tsv — configure what fails the build:
10202 IGNORE (Absence of Anti-CSRF Tokens)
10038 WARN (Content Security Policy Header Not Set)
40012 FAIL (Cross Site Scripting Reflected)
40014 FAIL (Cross Site Scripting Persistent)
90019 FAIL (Server Side Code Injection)
40018 FAIL (SQL Injection)
7 FAIL (Remote File Inclusion)GitLab CI
zap-security-scan:
image: zaproxy/zap-stable
stage: security
variables:
TARGET: $STAGING_URL
script:
- mkdir -p /zap/wrk
- zap-baseline.py
-t $TARGET
-c .zap/rules.conf
-r /zap/wrk/zap-report.html
-J /zap/wrk/zap-report.json
-x /zap/wrk/zap-report.xml
--auto
artifacts:
when: always
expire_in: 30 days
paths:
- /zap/wrk/zap-report.html
- /zap/wrk/zap-report.json
reports:
junit: /zap/wrk/zap-report.xml
only:
- merge_requests
- mainTuning ZAP: Reducing False Positives
Raw ZAP output is noisy. The baseline scan often returns 20-50 findings, many of which don't apply.
Per-Rule Configuration
Investigate each finding before ignoring it. Common false positives:
"X-Frame-Options Header Not Set" — if you're already using CSP with frame-ancestors, this is redundant. Mark as IGNORE.
"Absence of Anti-CSRF Tokens" — if you use SameSite=Strict cookies or custom CSRF headers, ZAP doesn't understand your implementation. Verify the real protection is in place, then IGNORE.
"Application Error Disclosure" — ZAP probes with malformed inputs and flags any error page. If your error pages don't expose stack traces or internal paths, mark as IGNORE.
Context-Based Scope Limitation
Limit scope to prevent scanning third-party embedded services:
contexts:
- name: myapp
includePaths:
- https://staging.example.com/api/.*
- https://staging.example.com/app/.*
excludePaths:
- https://staging.example.com/cdn-cgi/.*
- https://staging.example.com/analytics/.*
- .*\.google\.com.*Scanning Policies
Create custom scan policies to limit active attack types:
# Via API: create a policy that only tests injection
curl <span class="hljs-string">"http://localhost:8080/JSON/ascan/action/addScanPolicy/?scanPolicyName=injection-only"
curl <span class="hljs-string">"http://localhost:8080/JSON/ascan/action/setEnabledPolicies/?scanPolicyName=injection-only&ids=40012,40014,40018,90019"ZAP Automation Framework: Advanced Workflows
The Automation Framework replaces ad-hoc CLI flags with declarative YAML that version-controls with your project:
env:
contexts:
- name: staging
urls:
- https://staging.example.com
authentication:
method: form
parameters:
loginUrl: https://staging.example.com/login
loginRequestData: email={%username%}&password={%password%}
loggedInRegex: \QDashboard\E
users:
- name: tester
credentials:
username: test@example.com
password: TestPassword123
jobs:
- type: spider
parameters:
context: staging
user: tester
maxDuration: 10
maxDepth: 10
- type: spiderAjax
parameters:
context: staging
user: tester
maxDuration: 5
- type: passiveScan-wait
parameters:
maxDuration: 5
- type: activeScan
parameters:
context: staging
user: tester
policy: API-Scan
- type: report
parameters:
template: traditional-html-plus
reportDir: /zap/wrk/
reportFile: zap-report.html
- type: report
parameters:
template: sarif-json
reportDir: /zap/wrk/
reportFile: zap-report.sarifSARIF output integrates with GitHub Code Scanning:
- name: Upload SARIF report
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: zap-report.sarifThis surfaces ZAP findings directly in the GitHub Security tab and PR review interface.
What to Do with Findings
High-risk findings — block the PR or deployment. Assign to the team that owns the affected endpoint. Include the exact request/response from ZAP in the ticket.
Medium-risk findings — fix before the next release. Don't merge workarounds; fix the root cause.
Low/Informational findings — track in your backlog. Prioritize those that add up (five "low" header issues is a pattern worth addressing).
Confirmed false positives — document why they're false positives in your .zap/rules.tsv with a comment. Undocumented ignores are technical debt.
The metric to track isn't "zero findings" — it's that the same finding doesn't appear in consecutive scans. New findings in PRs mean the code review process isn't catching security issues early enough.