DAST for REST APIs: Automated Security Testing with OWASP ZAP and Nuclei

DAST for REST APIs: Automated Security Testing with OWASP ZAP and Nuclei

Dynamic Application Security Testing (DAST) attacks your running API to find vulnerabilities that static analysis misses: authentication bypass, BOLA/IDOR (Broken Object-Level Authorization), injection through actual HTTP requests, and security misconfigurations. This guide covers OWASP ZAP API Scan and Nuclei for REST API DAST in CI/CD pipelines.

Why REST APIs Need DAST

SAST reads your code. DAST runs against your live API — sending actual HTTP requests with malicious payloads, observing responses, and identifying vulnerabilities that only manifest at runtime.

What DAST finds that SAST misses:

  • Authentication bypass (JWT algorithm confusion, session fixation)
  • Broken Object-Level Authorization — user A accessing user B's data
  • Rate limiting missing on sensitive endpoints
  • Server-side request forgery (SSRF) via URL parameters
  • Security headers missing (CORS misconfiguration, missing CSP)
  • Verbose error messages exposing stack traces
  • HTTP methods not properly restricted

What DAST cannot find: Logic bugs, business rule violations, multi-step flows that require human understanding. DAST finds infrastructure and protocol-level issues; functional test coverage requires explicit test cases.

Tool 1: OWASP ZAP API Scan

OWASP ZAP's api-scan.py is a Docker-based scanner that takes an OpenAPI spec and attacks every endpoint automatically.

Prerequisites: Your API must be running and accessible. ZAP will crawl via the OpenAPI spec and attack discovered endpoints.

Run against a running API:

docker run -v $(pwd):/zap/wrk/:rw \
  ghcr.io/zaproxy/zaproxy:stable \
  zap-api-scan.py \
    -t http://host.docker.internal:8080/api/openapi.json \
    -f openapi \
    -r zap-report.html \
    -J zap-report.json \
    -z "-config scanner.threadPerHost=5"

host.docker.internal resolves to the Docker host — works for scanning localhost APIs from inside a container.

Scan a remote staging environment:

docker run -v $(pwd):/zap/wrk/:rw \
  ghcr.io/zaproxy/zaproxy:stable \
  zap-api-scan.py \
    -t https://api.staging.myapp.com/openapi.json \
    -f openapi \
    -r zap-report.html \
    -J zap-report.json \
    --fail-on warn

Add authentication (Bearer token):

# Create auth config file
cat > zap-auth.conf << EOF
replacer.full_list(0).description=auth-header
replacer.full_list(0).enabled=true
replacer.full_list(0).matchtype=REQ_HEADER
replacer.full_list(0).matchstr=Authorization
replacer.full_list(0).regex=false
replacer.full_list(0).replacement=Bearer eyJhbGci...your-jwt-token
EOF

docker run -v $(pwd):/zap/wrk/:rw \
  ghcr.io/zaproxy/zaproxy:stable \
  zap-api-scan.py \
    -t https://api.staging.myapp.com/openapi.json \
    -f openapi \
    -z "-configfile /zap/wrk/zap-auth.conf" \
    -r zap-report.html

Suppress false positives with rules file (zap-rules.conf):

# Suppress "X-Content-Type-Options header missing" globally
10021	IGNORE
# Suppress "CSP header not set" for specific URL
10038	IGNORE	url:https://api.staging.myapp.com/health
docker run -v $(pwd):/zap/wrk/:rw \
  ghcr.io/zaproxy/zaproxy:stable \
  zap-api-scan.py \
    -t https://api.staging.myapp.com/openapi.json \
    -f openapi \
    -c /zap/wrk/zap-rules.conf \
    -r zap-report.html

GitHub Actions integration:

name: DAST API Scan
on:
  push:
    branches: [main]

jobs:
  dast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Start API (Docker Compose)
        run: docker compose up -d api
      
      - name: Wait for API
        run: |
          timeout 60 bash -c 'until curl -sf http://localhost:8080/health; do sleep 2; done'
      
      - name: ZAP API Scan
        uses: zaproxy/action-api-scan@v0.9.0
        with:
          target: 'http://localhost:8080/api/openapi.json'
          format: openapi
          fail_action: true
          cmd_options: '-J zap-report.json'
          rules_file_name: 'zap-rules.conf'
      
      - name: Upload ZAP Report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: zap-api-report
          path: zap-report.json

What ZAP API Scan checks:

Check OWASP Category
SQL Injection A03:2021
XSS in JSON responses A03:2021
Path traversal A01:2021
Missing security headers A05:2021
Server information disclosure A05:2021
CORS misconfiguration A07:2021
Insecure HTTP methods (PUT/DELETE without auth) A01:2021
JWT algorithm confusion A02:2021

Tool 2: Nuclei for API Security

Nuclei is a template-based scanner with 9,000+ community-maintained templates targeting CVEs, misconfigurations, and API-specific vulnerabilities. It's faster than ZAP and easily extensible.

Install:

# macOS
brew install nuclei

# Linux
go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest

# Update templates
nuclei -update-templates

Basic API scan:

nuclei -target https://api.staging.myapp.com \
  -tags api,auth,token,jwt \
  -severity medium,high,critical \
  -json-export nuclei-report.json

API-specific template categories:

# JWT vulnerabilities
nuclei -target https://api.staging.myapp.com \
  -tags jwt \
  -j nuclei-jwt.json

# Authentication flaws
nuclei -target https://api.staging.myapp.com \
  -tags auth-bypass \
  -j nuclei-auth.json

# Security header checks
nuclei -target https://api.staging.myapp.com \
  -tags headers,misconfig \
  -j nuclei-headers.json

Custom Nuclei template for BOLA testing (checking if a user can access another user's resource):

# templates/bola-test.yaml
id: bola-order-endpoint

info:
  name: BOLA - Order ID Enumeration
  severity: high
  description: Tests if order endpoint enforces object-level authorization

http:
  - method: GET
    path:
      - "{{BaseURL}}/api/v1/orders/{{id}}"
    attack: sniper
    payloads:
      id:
        - "1"
        - "2"
        - "999"
        - "1000"
    headers:
      Authorization: "Bearer {{token}}"
    matchers-condition: and
    matchers:
      - type: status
        status: [200]
      - type: word
        words:
          - '"userId"'
        condition: and
    extractors:
      - type: regex
        name: user_id
        part: body
        regex:
          - '"userId":\s*"([^"]+)"'

Run it:

nuclei -target https://api.staging.myapp.com \
  -t templates/bola-test.yaml \
  -var token=$API_TOKEN \
  -j nuclei-bola.json

GitHub Actions with Nuclei:

- name: Nuclei API Security Scan
  uses: projectdiscovery/nuclei-action@main
  with:
    target: https://api.staging.myapp.com
    flags: "-tags api,jwt,auth -severity medium,high,critical"
    github-report: true
    github-token: ${{ secrets.GITHUB_TOKEN }}

Testing OWASP API Security Top 10

The OWASP API Security Top 10 defines the most critical API risks. Here's how to test each:

API1: Broken Object-Level Authorization (BOLA/IDOR)

# Login as user A, get their order ID
# Attempt to access with user B's token
curl -H "Authorization: Bearer $USER_B_TOKEN" \
  https://api.staging.myapp.com/api/v1/orders/$USER_A_ORDER_ID
# Expected: 403 Forbidden
# Vulnerable: 200 OK with user A's data

API2: Broken Authentication

# Test JWT algorithm confusion (none algorithm)
# Decode token, change alg to "none", remove signature
python3 -c "
import base64, json
header = json.dumps({'alg': 'none', 'typ': 'JWT'}).encode()
payload = json.dumps({'sub': '1', 'role': 'admin'}).encode()
token = base64.urlsafe_b64encode(header).rstrip(b'=') + b'.' + \
        base64.urlsafe_b64encode(payload).rstrip(b'=') + b'.'
print(token.decode())
"
# Try the unsigned token — should get 401, not data

API3: Broken Object Property-Level Authorization (Mass Assignment)

# Send extra fields that should not be settable by users
curl -X PATCH https://api.staging.myapp.com/api/v1/users/me \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "John", "role": "admin", "isActive": true}'
# Check response: did role change in subsequent GET /me?

API5: Broken Function-Level Authorization

# Test admin endpoints without admin token
curl -X DELETE https://api.staging.myapp.com/api/v1/admin/users/123 \
  -H "Authorization: Bearer $REGULAR_USER_TOKEN"
# Expected: 403 Forbidden

Integrating DAST into CI Without Slowing Down

DAST is slower than SAST. A ZAP scan can take 5-30 minutes. Structure it so it doesn't block developer velocity:

# Strategy: DAST runs on staging after merge to main
# Not on every PR — too slow

on:
  push:
    branches: [main]  # After merge, not on PR

jobs:
  dast:
    runs-on: ubuntu-latest
    environment: staging  # Deploy to staging first, then scan
    steps:
      - name: Deploy to Staging
        run: ./deploy.sh staging
      
      - name: Wait for Deployment
        run: /usr/local/bin/await 'curl -sf https://api.staging.myapp.com/health'
      
      - name: ZAP Scan
        uses: zaproxy/action-api-scan@v0.9.0
        with:
          target: 'https://api.staging.myapp.com/openapi.json'
          fail_action: false  # Report but don't block
      
      - name: Notify on New Findings
        if: failure()
        uses: 8398a7/action-slack@v3
        with:
          status: failure
          text: "DAST scan found new vulnerabilities on staging"

Summary

Tool Best For Speed False Positive Rate
OWASP ZAP Comprehensive crawl + attack Slow (10-30m) Medium
Nuclei Template-based CVE + misconfig Fast (1-5m) Low
Custom scripts BOLA, business logic Varies Minimal

Run ZAP weekly against staging for comprehensive coverage. Run Nuclei on every deployment for fast CVE and misconfiguration checks. Write custom scripts for your specific business logic vulnerabilities (BOLA is the hardest to automate but the most common API vulnerability).

DAST is not optional for APIs exposed to the internet. It finds real exploits that no amount of code review catches.

Read more

Start now free