OWASP ZAP Guide: Automated Security Testing for Developers
Complete guide to OWASP ZAP — headless mode, API scanning, authentication handling, and integrating ZAP into Jenkins and GitHub Actions pipelines.
OWASP ZAP Guide: Automated Security Testing for Developers
OWASP ZAP (Zed Attack Proxy) is the most widely deployed open-source web application security scanner. Originally designed as a GUI tool for manual testing, ZAP has evolved into a powerful automation engine suitable for DevSecOps pipelines. It detects vulnerabilities including XSS, SQL injection, CSRF, insecure headers, and misconfigurations through active and passive scanning. This guide focuses on the headless automation use cases that matter most for engineering teams: scanning APIs, handling authentication, and integrating ZAP into CI/CD workflows.
Installation
Docker (recommended for CI):
docker pull zaproxy/zap-stable
macOS/Linux (native):
# Download from https://www.zaproxy.org/download/
# Or via package managers
brew install --cask owasp-zap
For automation workflows, the Docker image is strongly preferred — it eliminates Java version conflicts and works identically across local and CI environments.
Running ZAP in Headless Mode
ZAP provides three levels of automated scanning via command-line:
Baseline scan — passive only, no active attacks. Safe to run against production:
docker run --rm zaproxy/zap-stable zap-baseline.py \
-t https://example.com \
-r zap-baseline-report.html
API scan — targeted at REST/GraphQL APIs using an OpenAPI or GraphQL schema:
docker run --rm -v $(pwd):/zap/wrk zaproxy/zap-stable zap-api-scan.py \
-t /zap/wrk/openapi.yaml \
-f openapi \
-r zap-api-report.html
Full scan — active scanning with attack payloads. Only run against non-production environments:
docker run --rm zaproxy/zap-stable zap-full-scan.py \
-t https://staging.example.com \
-r zap-full-report.html \
-J zap-results.json
The -J flag produces JSON output alongside the HTML report. The -x flag produces XML. Use -z "-config api.addrs.addr.name=.* -config api.addrs.addr.regex=true" to pass raw ZAP configuration options.
Exit codes: 0 = no warnings or failures, 1 = warnings only, 2 = failures present. Use exit code 2 as your CI gate.
API Scanning with OpenAPI and GraphQL
ZAP's API scanner imports your schema and constructs targeted test cases for every endpoint and parameter.
OpenAPI/Swagger:
docker run --rm -v $(pwd):/zap/wrk zaproxy/zap-stable zap-api-scan.py \
-t https://api.example.com/openapi.json \
-f openapi \
-r api-scan.html \
-J api-results.json \
-z "-config scanner.strength=High"
ZAP discovers all endpoints from the spec, sends baseline requests to establish a baseline response, then injects attack payloads (SQL injection, XSS, path traversal, etc.) into each parameter.
GraphQL:
docker run --rm -v $(pwd):/zap/wrk zaproxy/zap-stable zap-api-scan.py \
-t https://api.example.com/graphql \
-f graphql \
-r graphql-scan.html
ZAP performs introspection to discover the schema, then tests each field and mutation. For schemas with disabled introspection, provide the schema file directly with -S /zap/wrk/schema.graphql.
Targeting specific endpoints. Create a context file to restrict scanning to a subset of your API:
<!-- context.xml -->
<context>
<name>API Context</name>
<incregexes>
<string>https://api.example.com/v1/.*</string>
</incregexes>
<excregexes>
<string>https://api.example.com/v1/health</string>
</excregexes>
</context>
Handling Authentication
ZAP's usefulness against authenticated endpoints depends on proper auth configuration. There are three main approaches for headless automation.
Option 1: Pass a static Bearer token. Simplest approach when you can generate a long-lived test token:
docker run --rm zaproxy/zap-stable zap-api-scan.py \
-t https://api.example.com/openapi.json \
-f openapi \
-z "-config replacer.full_list(0).description=auth-header \
-config replacer.full_list(0).enabled=true \
-config replacer.full_list(0).matchtype=REQ_HEADER \
-config replacer.full_list(0).matchstr=Authorization \
-config replacer.full_list(0).replacement='Bearer YOUR_TOKEN_HERE'"
Option 2: ZAP Authentication Script. For OAuth flows or cookie-based auth, write a ZAP authentication script in JavaScript or Python. Store it in a volume-mounted directory:
// auth-script.js
function authenticate(helper, paramsValues, credentials) {
const loginUrl = paramsValues.get("loginUrl");
const username = credentials.getParam("username");
const password = credentials.getParam("password");
const loginData = "username=" + username + "&password=" + password;
const msg = helper.prepareMessage();
msg.setRequestBody(loginData);
const requestUri = new org.apache.commons.httpclient.URI(loginUrl, true);
const requestHeader = new org.parosproxy.paros.network.HttpRequestHeader(
org.parosproxy.paros.network.HttpRequestHeader.POST,
requestUri,
org.parosproxy.paros.network.HttpHeader.HTTP11
);
msg.setRequestHeader(requestHeader);
helper.sendAndReceive(msg);
return msg;
}
Option 3: Selenium-driven login (full-scan). For complex SPA login flows, use a ZAP automation framework YAML that drives a browser to log in, capture the session, and then scan:
# zap-automation.yaml
env:
contexts:
- name: "Authenticated"
urls: ["https://staging.example.com"]
authentication:
method: "browser"
parameters:
loginPageUrl: "https://staging.example.com/login"
loginPageWait: 2
verification:
method: "response"
loggedInRegex: "\"user\":"
loggedOutRegex: "\"login_required\":"
users:
- name: "testuser"
credentials:
username: "testuser@example.com"
password: "${ZAP_TEST_PASSWORD}"
jobs:
- type: activeScan
parameters:
context: "Authenticated"
user: "testuser"
Run it:
docker run --rm -v $(pwd):/zap/wrk \
-e ZAP_TEST_PASSWORD=$TEST_PASSWORD \
zaproxy/zap-stable zap.sh -cmd \
-autorun /zap/wrk/zap-automation.yaml
Tuning Scan Rules
ZAP has hundreds of scan rules (plugins). Disable noisy or irrelevant rules to improve signal quality:
# Disable specific rules by ID
docker run --rm zaproxy/zap-stable zap-full-scan.py \
-t https://staging.example.com \
-r report.html \
-z "-config scanner.excludedParameters=JSESSIONID,_ga \
-config rules.cookie.ignoreCookies=_ga,_gid"
Find rule IDs in the ZAP GUI under Tools > Options > Active Scan or in the ZAP Rules documentation.
GitHub Actions Integration
name: ZAP Security Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
zap-api-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start application
run: docker compose up -d
# Wait for app to be ready
- name: Wait for app
run: |
timeout 60 bash -c 'until curl -sf http://localhost:3000/health; do sleep 2; done'
- name: ZAP API Scan
uses: zaproxy/action-api-scan@v0.7.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
target: "http://localhost:3000/openapi.json"
format: openapi
fail_action: true
issue_title: ZAP API Scan Report
rules_file_name: .zap/rules.tsv
cmd_options: "-J results.json"
- name: Upload ZAP results
uses: actions/upload-artifact@v4
if: always()
with:
name: zap-results
path: |
report_html.html
results.json
The zaproxy/action-api-scan action automatically creates a GitHub issue with the scan report, making findings visible without requiring teams to parse logs.
Jenkins Integration
pipeline {
agent any
environment {
ZAP_TARGET = "https://staging.example.com"
}
stages {
stage('ZAP Baseline Scan') {
steps {
sh '''
docker run --rm \
-v ${WORKSPACE}:/zap/wrk \
zaproxy/zap-stable zap-baseline.py \
-t ${ZAP_TARGET} \
-r baseline-report.html \
-J baseline-results.json \
-I
'''
}
post {
always {
publishHTML(target: [
allowMissing: true,
alwaysLinkToLastBuild: false,
keepAll: true,
reportDir: '.',
reportFiles: 'baseline-report.html',
reportName: 'ZAP Baseline Report'
])
}
}
}
}
}
The -I flag makes ZAP exit with code 0 even if issues are found — useful during initial pipeline setup before establishing your baseline.
Filtering Results and Reducing False Positives
Create a .zap/rules.tsv file to configure rule behavior per-project:
10096 IGNORE (Web Browser XSS Protection Not Enabled)
10021 IGNORE (X-Content-Type-Options Header Missing)
40018 WARN (SQL Injection)
40012 FAIL (Cross Site Scripting (Reflected))
Columns: Rule ID, Alert Level (IGNORE, WARN, FAIL), Comment. Rules set to IGNORE do not appear in reports. FAIL causes the scan to exit with a failure code.
Reference the rules file in your scan command with -r .zap/rules.tsv or the GitHub Action's rules_file_name parameter. Version-control this file alongside your code so suppressions are auditable.