OWASP ZAP Testing Guide: API, Authenticated Scans & CI/CD Integration

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=true

Python API Client

pip install python-owasp-zap-v2.4
from 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.html

Run it:

docker run --rm \
  -v $(<span class="hljs-built_in">pwd):/zap/wrk:rw \
  zaproxy/zap-stable \
  zap.sh -cmd -autorun /zap/wrk/auth.yaml

Token-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.js

auth-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.html

CI/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
    - main

Tuning 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.sarif

SARIF output integrates with GitHub Code Scanning:

- name: Upload SARIF report
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: zap-report.sarif

This 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.

Read more