Overview
Per zaproxy.org/docs/docker/baseline-scan/ (opens in new window):
"The ZAP Baseline scan is a script that is available in the ZAP Docker images. It runs the ZAP spider against the specified target for (by default) 1 minute and then waits for the passive scanning to complete before reporting the results."
The baseline scan is passive only - non-intrusive, safe for production. The companion zap-full-scan.py adds active scanning (injection probes, XSS payloads) and is NOT safe for production; reserve for staging.
When to use
Step 1 - Install
ZAP baseline runs from the official Docker image - no host install needed. Per zap-base (opens in new window):
docker pull ghcr.io/zaproxy/zaproxy:stableImage variants:
| Tag | Use |
|---|---|
:stable | Production-grade; pin in CI |
:weekly | Latest features; for evaluation |
:bare | Headless minimal; smallest |
Step 2 - First baseline scan
Per zap-base (opens in new window) (verbatim):
docker run -v $(pwd):/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py \
-t https://www.example.com -r testreport.htmlThe -v $(pwd):/zap/wrk/:rw mount makes the report writable to the host directory. The report file lands at ./testreport.html after the scan.
Step 3 - Common flags
Per zap-base (opens in new window):
| Flag | Use |
|---|---|
-t URL | Target URL (required) |
-r FILE | HTML report output |
-w FILE | Markdown report |
-x FILE | XML report |
-J FILE | JSON report (for finding triage) |
-c FILE | Config file: rule INFO/IGNORE/FAIL behavior |
-P PORT | Specify ZAP listen port |
-j | Use Ajax spider in addition to traditional spider (JS-heavy apps) |
-m MINS | Spider duration in minutes (default 1) |
-d | Show debug messages |
-I | Don't return failure on warning (gate softly) |
-n CONTEXT_FILE | Authenticated scan context file |
Step 4 - Authenticated scans
For apps requiring login, export a ZAP context file from the GUI (Sites → right-click → Export Context). The context file encodes:
Building that context from scratch - choosing the auth method, wiring session management, calibrating verification indicators, CSRF and OAuth/bearer handling - is walked end to end in references/auth.md. Then:
docker run -v $(pwd):/zap/wrk/:rw \
-e ZAP_AUTH_USERNAME=$USER \
-e ZAP_AUTH_PASSWORD=$PASS \
-t ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t https://app.example.com -n /zap/wrk/context.xml -J report.jsonStep 5 - Active scan companion (zap-full-scan.py)
For staging-only deeper analysis:
docker run -v $(pwd):/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable \
zap-full-scan.py -t https://staging.example.com -J report.jsonzap-full-scan.py triggers active payloads (SQLi probes, XSS, SSRF). NEVER point at production - risk of data corruption + generates audit-log noise.
Step 6 - False-positive triage (MANDATORY)
Per zap-base (opens in new window), rule customization via -c config_file where each line is <rule_id>\t<INFO|WARN|IGNORE|FAIL>\t<URL_pattern>:
Example zap-config.tsv:
10049 IGNORE * # Cookie No HttpOnly: legacy session cookie; tracked in JIRA-1234
40012 WARN https://app.example.com/admin/* # XSS: legacy admin pages; not exposed to users
10063 FAIL * # Permissions Policy: must be set on all responsesThree suppression layers:
| Mechanism | Example | When to use |
|---|---|---|
| Per-rule config TSV | <rule_id>\tIGNORE\t* | Rule disabled globally |
| Per-URL pattern | <rule_id>\tWARN\thttps://app/admin/* | Rule downgraded for known-old code path |
| Context exclusion | <exclude_from_context> in context XML | Whole URL trees out of scope |
-I flag | zap-baseline.py -I ... | Soft gate: warns but exits 0 |
Justification template (mandatory in config):
# Rule 10049 (Cookie No HttpOnly)
# Suppressed: 2026-05-15 by alice@example.com
# Reason: legacy session cookie; tracked in JIRA-1234; expires 2026-09-15
# Re-review-date: 2026-09-15
10049 IGNORE *Cadence: every quarter, audit the config TSV - every IGNORE entry should have an Re-review-date. Past-due entries are removed + re-evaluated.
Step 7 - Output formats for cross-tool triage
docker run -v $(pwd):/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t https://app.example.com -J zap-report.jsonThe JSON report feeds cross-tool aggregation. For SARIF output (GitHub Code Scanning), use a converter (zap-sarif container action).
Step 8 - CI integration
GitHub Actions:
jobs:
zap-baseline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: zaproxy/action-baseline@v0.13.0
with:
target: 'https://staging.example.com'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-J zap-report.json'
- uses: actions/upload-artifact@v4
if: always()
with: { name: zap-report, path: zap-report.json }The official zaproxy/action-baseline action wraps the Docker invocation. Auto-creates a GitHub Issue on failure.
Designing the full team cadence around this job - PR-blocking passive baseline, nightly ZAP full + nuclei active layer, the baseline-finding ratchet for legacy apps, dedup, and coverage measurement - is in references/cadence.md.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Run zap-full-scan.py against production | Active payloads pollute / damage data | Staging-only (Step 5) |
Skip -J JSON output | Can't feed finding triage; report is HTML-only | Always pass -J (Step 7) |
| Ignore Ajax spider for SPAs | Missing routes; coverage gap | Add -j flag (Step 3) |
| Hardcode credentials in context file | Secrets leak in repo | Reference env vars (Step 4) |
Suppress without # Re-review-date | Permanent debt | Required template (Step 6) |
Limitations
References
Companion reference for zap-baseline. Unauthenticated DAST scans cover only the public attack surface - for most apps, 70-90% of routes sit behind a login wall. This build-an-X workflow walks the full authenticated session setup: ZAP Context creation, choosing the right Authentication Method, wiring Session Management, calibrating logged-in/out indicators, handling CSRF tokens, injecting OAuth/bearer headers, and exporting the context file that zap-baseline.py -n context.xml (and zap-full-scan.py) consume in CI.
How to use
Step 1 - Create a ZAP Context
Per zaproxy.org/docs/desktop/start/features/authentication/ (opens in new window), authentication in ZAP is always scoped to a Context - a named set of URLs. Create one before touching any auth setting:
All auth settings attach to this Context. CLI scans reference it via -n context.xml (Step 11).
Step 2 - Choose the Authentication Method
Per zaproxy.org/docs/desktop/start/features/authmethods/ (opens in new window), ZAP supports five built-in methods. Choose by app login mechanism:
| App login type | Method to use |
|---|---|
| HTML form POST with username + password fields | Form-Based |
JSON POST {"username":"...","password":"..."} | JSON-Based |
| HTTP Basic / Digest / NTLM challenge | HTTP/NTLM |
| Custom flow (OTP, magic link, multi-step) | Script-Based |
| Modern browser-rendered SSO / OAuth redirect | Browser-Based (auth-helper addon) |
Step 3 - Configure Form-Based Authentication
Per zap-methods (opens in new window), Form-Based auth requires:
Per zap-methods (opens in new window), Form-Based auth supports re-authentication - ZAP detects session expiry and re-logs in automatically mid-scan.
CSRF token handling for form login: if the login form contains an anti-CSRF token field, configure its name in Tools > Options > Anti CSRF Tokens (per zap-auth (opens in new window)). ZAP fetches the login page, extracts the token, and replays it with the POST automatically.
Step 4 - Configure JSON-Based Authentication
Per zap-methods (opens in new window), JSON-Based auth is for apps whose login endpoint accepts a JSON body rather than form-encoded params:
ZAP sends Content-Type: application/json automatically. Supports re-authentication. Use this for REST API login endpoints returning a session cookie or JWT response body.
Step 5 - Configure Script-Based Authentication
Per zap-methods (opens in new window), Script-Based auth handles flows that Form-Based and JSON-Based cannot: OTP-augmented logins, multi-step forms, OAuth authorization-code flows with PKCE, or apps that rotate CSRF seeds on every page load. It requires the Script Console add-on and an Authentication script that builds the login request via helper.prepareMessage().
See script-based-auth.md (opens in new window) for the Script Console setup, the Groovy authenticate() skeleton, and the OAuth authorization-code exchange pattern.
Step 6 - Configure Browser-Based Authentication (auth-helper addon)
Per zaproxy.org/docs/desktop/addons/authentication-helper/ (opens in new window), the Authentication Helper add-on provides Browser-Based Authentication for apps that use JS-rendered login pages, SSO redirects, or WebAuthn flows that headless HTTP clients cannot replay:
authentication:
method: "browser"
parameters:
loginPageUrl: "https://app.example.com/login"
verification:
method: "autodetect"
sessionManagement:
method: "autodetect"ZAP launches Firefox, navigates to loginPageUrl, fills the username and password fields, and captures the resulting session token. The autodetect verification asks ZAP to find a suitable verification URL automatically.
Step 7 - Configure Session Management
Per zaproxy.org/docs/desktop/start/features/sessionmanagement/ (opens in new window), ZAP supports three session management methods. Set in Session Properties > Context > Session Management:
| App session type | Method |
|---|---|
Session ID in a cookie (JSESSIONID, session, etc.) | Cookie-Based Session Management |
Authorization header (Basic, JWT Bearer) | HTTP Authentication Session Management |
| Custom header or token rotation | Script-Based Session Management |
Per zap-session (opens in new window), Cookie-Based "session is being tracked through cookies" and tokens are imported from the HTTP Sessions Extension.
Per zap-session (opens in new window), Script-Based "is called whenever session management actions are performed" and requires the Scripts Console add-on.
Step 8 - Inject OAuth/Bearer Tokens via Environment Variables
Per zap-auth (opens in new window), ZAP exposes environment variables (ZAP_AUTH_HEADER_VALUE, ZAP_AUTH_HEADER, ZAP_AUTH_HEADER_SITE) for header-based injection of pre-obtained bearer tokens - OAuth client-credentials, API keys, CI-issued JWTs. Set them in the CI environment before the scan. For a full authorization-code exchange, use Script-Based auth (Step 5) instead and let ZAP manage token refresh.
See oauth-bearer-injection.md (opens in new window) for the variable table and the CI Docker example.
Step 9 - Set Authentication Verification Strategy
Per zap-verify (opens in new window), ZAP uses an Authentication Verification Strategy to know whether a request runs as an authenticated user, driven by a Logged-In Indicator and a Logged-Out Indicator regex. Four strategies exist (Check Every Response, Check Every Request, Check Every Request or Response, Poll the Specified URL); calibrate the indicators by flagging logged-in and logged-out responses in the History tab.
See verification-strategy.md (opens in new window) for the indicator examples, the strategy-selection table, and the calibration steps.
Step 10 - Add Users
Per zaproxy.org/docs/desktop/start/features/users/ (opens in new window), users are configured per-context at Session Properties > Context > Users > Add. Each user stores credentials that map to the Authentication Method's {%username%} / {%password%} placeholders.
Per zap-users (opens in new window): "Authentication Methods define the process; Users store the specific credentials needed for each user account." One context can hold multiple users (admin, read-only, unauthenticated) to test privilege separation in a single scan.
Never store plaintext credentials in the exported context XML committed to version control. Reference environment variables in CI (Step 8 pattern) or use ZAP's -config CLI flag to inject credentials at scan time:
zap-full-scan.py -t https://app.example.com \
-n /zap/wrk/context.xml \
-config context.users\(0\).name=scanner \
-config context.users\(0\).credentials.username=$ZAP_USER \
-config context.users\(0\).credentials.password=$ZAP_PASSStep 11 - Export the Context XML
Once auth is confirmed working via the Authentication Tester (per zap-helper (opens in new window), under Tools > Authentication Tester or Ctrl+T), export the Context:
File > Export Context > save as context.xml
Commit context.xml to the repo at .zap/context.xml. The file encodes auth method, session management strategy, verification strategy, and include/exclude URL patterns. It does NOT contain user credentials when users are configured with the -config override pattern above.
Use in CI:
docker run --rm \
-e ZAP_AUTH_USERNAME=$ZAP_USER \
-e ZAP_AUTH_PASSWORD=$ZAP_PASS \
-v $(pwd):/zap/wrk/:rw \
ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t https://app.example.com -n /zap/wrk/.zap/context.xml -J report.jsonPer ../SKILL.md (opens in new window), the -n CONTEXT_FILE flag loads this file and activates authentication for the scan.
Step 12 - Replay with Burp Suite
For apps already configured in ZAP, mirror the session in Burp for manual testing by capturing a valid authenticated request via ZAP proxy, then:
This keeps Burp and ZAP scanning the same authenticated surface without re-configuring login from scratch in each tool.
Worked example
A team needs authenticated DAST coverage of a form-login SPA at app.example.com whose login page carries an anti-CSRF token.
Result: the baseline scan authenticates, re-logs in when the session expires, and reports vulnerabilities across the routes behind the login wall instead of only the public pages.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Skip context creation, use -u user:pass flag | No re-auth; spider logs out mid-scan | Context + auth method (Steps 1-3) |
| Hardcode credentials in context.xml | Secrets leak in version control | -config injection or env vars (Step 10) |
| No logged-out indicator | ZAP reports false coverage on expired sessions | Calibrate both indicators (Step 9) |
| Form-based auth on a JSON-API login | ZAP sends form-encoded body; app rejects it | JSON-based auth (Step 4) |
Exclude /login from context scope | Auth POST never proxied; ZAP can't authenticate | Include login URL; exclude only /logout (Step 1) |
| Browser-based auth without auth-helper addon | method: browser is not a built-in; scan fails | Install Authentication Helper from Marketplace (Step 6) |
| Set verification strategy but no indicators | Strategy is inactive; ZAP never detects re-auth need | Supply at least one logged-in regex (Step 9) |
Limitations
Sources
Companion reference for zap-baseline. Consult when a team adopts DAST from scratch, restructures scan cadence, or is drowning in findings with no triage discipline. Layers ZAP passive baseline and active scanning (ZAP full scan + nuclei templates) across PR / nightly windows, with a baseline ratchet so pre-existing findings do not block PRs.
Step 1 - Layer the scans by intrusiveness
| Layer | Scan type | Cadence | Target | Risk |
|---|---|---|---|---|
| 1 | Passive baseline (ZAP baseline) | Per-PR (blocking) | Staging | Safe - passive only |
| 2 | Active scan (ZAP full scan + nuclei templates) | Nightly | Staging | Active probes - pollute staging data |
The PR-blocking layer is intentionally narrow - only fail on findings that didn't exist before. That requires the baseline ratchet (Step 2). Nuclei (nuclei-dast skill) complements the nightly ZAP full scan with template-driven checks; its JSONL output feeds the same aggregation layer.
Step 2 - Baseline-finding ratchet
The first scan against a legacy app surfaces 100s of pre-existing findings; if they all block PRs, the team disables DAST. The ratchet pattern:
# pr-gate.py
import json
def diff_findings(current, baseline):
baseline_keys = {(f['file'], f['rule_id']) for f in baseline}
new = [f for f in current if (f['file'], f['rule_id']) not in baseline_keys]
return new
with open('current.json') as f:
current = json.load(f)
with open('baseline.json') as f:
baseline = json.load(f)
new_findings = diff_findings(current, baseline)
if any(f['severity'] in ['critical', 'high'] for f in new_findings):
print(f"FAIL: {len(new_findings)} new finding(s) on PR; not in baseline")
exit(1)ZAP baseline natively supports per-rule gating via the -c config.tsv rule file; mirror the pattern for the cross-tool aggregation layer.
Step 3 - Alert deduplication across runs
Consecutive PR-runs catch the same vulnerability multiple times; each PR comment shows duplicate noise. Dedupe by (rule_id, url, parameter) tuple:
def dedupe_findings(findings):
seen = set()
deduped = []
for f in findings:
key = (f['rule_id'], f['url'], f.get('parameter', ''))
if key not in seen:
seen.add(key)
deduped.append(f)
return dedupedCross-tool dedup is handled at the aggregation layer (security-finding-triager); this dedup is per-tool per-run.
Step 4 - CI cadence
Two workflows implement the layering:
| Workflow file | Trigger | Job |
|---|---|---|
.github/workflows/dast.yml | pull_request | ZAP baseline + dast-pr-gate.py (Step 2) |
.github/workflows/dast-nightly.yml | cron: 0 2 * * * | ZAP full scan + nuclei template scan |
# .github/workflows/dast.yml - PR-blocking baseline
on:
pull_request:
branches: [main]
jobs:
zap-baseline-pr:
name: DAST baseline (PR-blocking)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: zaproxy/action-baseline@v0.13.0
with:
target: ${{ secrets.STAGING_URL }}
rules_file_name: '.zap/rules.tsv'
- run: python ci/dast-pr-gate.py current.json .zap/baseline-findings.json# .github/workflows/dast-nightly.yml - nightly active scan
on:
schedule:
- cron: '0 2 * * *' # 2 AM daily
workflow_dispatch:
jobs:
zap-full-scan:
name: DAST active full scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: zaproxy/action-full-scan@v0.13.0
with:
target: ${{ secrets.STAGING_URL }}
- uses: actions/upload-artifact@v4
with: { name: zap-full-report, path: report_html.html }
nuclei:
name: DAST template scan (nuclei)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: |
nuclei -u ${{ secrets.STAGING_URL }} -jsonl -o nuclei.jsonl
- uses: actions/upload-artifact@v4
with: { name: nuclei-report, path: nuclei.jsonl }Nuclei flag details are in the nuclei-dast skill.
Step 5 - Per-finding triage workflow
When a new finding appears in a PR-blocking scan, the team has 4 options:
Each option requires reviewer + reason + Re-review-date in commit message or PR comment. No silent suppression.
Step 6 - Coverage measurement
Post-scan, measure coverage to detect blind spots:
# How many endpoints did the scan cover?
jq '.spider_results.urls | length' report.json
# How many endpoints did the OpenAPI spec define?
jq '.paths | length' openapi.yaml
# Coverage ratioIf coverage < 80% of API surface, the spider missed routes; investigate auth flows (auth.md (opens in new window)), JS-heavy SPAs, route-discovery gaps.
Step 7 - Aggregate cross-tool findings
Once both tools run, aggregate each tool's output:
zap-baseline.py -t $URL -J zap.json
nuclei -u $URL -jsonl -o nuclei.jsonl
# Aggregate both + emit unified verdictThe aggregation layer (the security-finding-triager agent + multi-tool-finding-triage) handles cross-tool dedup, severity normalization, and waiver enforcement.
Anti-patterns specific to DAST cadence
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Run full active scans on every PR | Scan time blows out CI; staging data corrupted | Baseline-only on PR; full nightly (Step 4) |
| Skip baseline ratchet | Legacy findings block every PR | Baseline + diff (Step 2) |
| Ignore coverage measurement | Missing endpoints unscanned silently | Step 6 weekly check |
| One scan per app, never re-baseline | Baseline grows stale; misses regressions in old code | Quarterly re-baseline + waiver review |
| Run ZAP + nuclei without dedup | Same finding shows twice | Aggregate via triager (Step 7) |
End-to-end cadence checklist
Limitations
Sources
Per zap-auth (opens in new window), ZAP exposes three environment variables for header-based authentication injection - useful for pre-obtained bearer tokens (OAuth client-credentials flow, API keys, CI-issued JWTs):
| Variable | Purpose |
|---|---|
ZAP_AUTH_HEADER_VALUE | The token value (Bearer eyJ...) |
ZAP_AUTH_HEADER | Header name (defaults to Authorization if unset) |
ZAP_AUTH_HEADER_SITE | Restrict injection to this domain only |
Set these in the CI environment before running the scan:
export ZAP_AUTH_HEADER_VALUE="Bearer $(./scripts/get-ci-token.sh)"
export ZAP_AUTH_HEADER_SITE="app.example.com"
docker run --rm \
-e ZAP_AUTH_HEADER_VALUE \
-e ZAP_AUTH_HEADER_SITE \
-v $(pwd):/zap/wrk/:rw \
ghcr.io/zaproxy/zaproxy:stable \
zap-full-scan.py -t https://app.example.com -n /zap/wrk/context.xml -J report.jsonFor OAuth flows requiring a full authorization-code exchange, use Script-Based auth (references/script-based-auth.md (opens in new window)) to run the exchange inside ZAP and let ZAP manage token refresh during the scan. Environment-variable injection is the right path for client-credentials and static-API-key auth.
Per zap-methods (opens in new window), Script-Based auth handles flows that Form-Based and JSON-Based cannot: OTP-augmented logins, multi-step forms, OAuth authorization-code flows with PKCE, or apps that rotate CSRF seeds on every page load.
Prerequisites:
Minimal skeleton (Groovy):
def authenticate(helper, paramsValues, credentials) {
def loginUrl = paramsValues.get("Login URL")
def msg = helper.prepareMessage()
msg.setRequestHeader("POST " + loginUrl + " HTTP/1.1\r\n" +
"Host: app.example.com\r\n" +
"Content-Type: application/json\r\n")
def body = '{"user":"' + credentials.getParam("Username") + '",' +
'"pass":"' + credentials.getParam("Password") + '"}'
msg.setRequestBody(body)
helper.sendAndReceive(msg)
return msg
}Select the script in Session Properties > Context > Authentication > Script-Based Authentication, then set any script parameters.
For OAuth authorization-code flows: the script fetches the /authorize redirect, extracts the code, POSTs to /token, and stores the resulting access_token in a ZAP environment variable for header injection (see references/oauth-bearer-injection.md (opens in new window)).
Per zap-verify (opens in new window), ZAP uses an Authentication Verification Strategy to know whether a request is executing as an authenticated user. Configure in Session Properties > Context > Authentication > Verification:
Logged-In Indicator: a regex present in responses when the user is authenticated. Examples:
Logged-Out Indicator: a regex present in responses when the session has expired. Examples:
Per zap-verify (opens in new window), four strategies are available:
| Strategy | Use when |
|---|---|
| Check Every Response | Traditional HTML apps (indicator in page body) |
| Check Every Request | Client-side sessions (JWT in Authorization header) |
| Check Every Request or Response | Mixed; SPA + API combo |
| Poll the Specified URL | Dedicated /api/me or /session/check endpoint |
Calibration steps: