API Testing & Quality Engineering
Hands-onIntermediate 35 min· Beyond functional testing · 14 of 19

Security testing basics with OWASP ZAP

Scan your own API with OWASP ZAP in Docker, read and triage the alerts, fix the easy ones and learn what automated scanners cannot find, so you can add basic security testing to your toolbox.

In this tutorial you will run your first automated security scan on your own API using OWASP ZAP (Zed Attack Proxy), a free, open-source tool maintained by the OWASP community. You will scan the Brew & Bean API, read the alerts, fix the easy ones and learn what a scanner can and cannot find.

You need: Docker installed (the easiest way to run ZAP), and the coffee shop API from *Automating API tests in code* running locally. Basic HTTP knowledge helps; see the first lessons of this track.

This is a security *testing* lesson from the tester's side. Deeper topics (how attacks work, authentication design, secure coding) are in the Security track.

Scan only what you own or have written permission to test

ZAP sends many unusual requests. Scanning a website or API that is not yours, without permission, can be illegal (in India, the IT Act covers unauthorised access) and can disrupt a real service. In this lesson you scan only an API running on your own computer. For practice beyond your own apps, use intentionally vulnerable labs made for learning, such as OWASP Juice Shop or DVWA, run locally or in a lab you control.

Why testers do security checks

A bug that breaks a feature is annoying. A bug that lets one customer read another customer's order is a data breach. Quality engineers are well placed to catch the cheap, common problems early: missing security headers, error messages that leak internals, guessable IDs, missing login checks.

The usual reference list is the OWASP Top 10 (web) and the OWASP API Security Top 10. Examples that matter for an order API: broken access control (reading someone else's order by changing an id), excessive data in responses (returning phone numbers to everyone), and poor configuration (stack traces or missing headers).

The plan for this lesson

  1. 1Start the API on your own machine.
  2. 2Run the ZAP baseline scan in Docker and open the HTML report.
  3. 3Triage each alert, add security headers and scan again.
  4. 4Run the OpenAPI-based API scan and add tests for what scanners miss.

Kinds of automated security testing

KindWhat it doesExample tool
DAST (dynamic)Attacks a running app from outside, like a user wouldOWASP ZAP
SAST (static)Reads source code for risky patterns without running itSemgrep, Bandit (Python), CodeQL
Dependency scanChecks libraries for known vulnerabilitiespip-audit, npm audit, Dependabot
Manual testingA person tries logic flaws a scanner cannot understandBrowser, curl, Postman, ZAP proxy

ZAP is a DAST tool. It has a desktop app and, importantly for testers, ready-made scripts that run in Docker and in CI. We use those.

Step 1: Start the API

Terminal 1
uvicorn main:app --host 0.0.0.0 --port 8000

--host 0.0.0.0 lets the ZAP container reach the API through your machine. On a shared network use 127.0.0.1 and a Docker host setting that supports it, or scan only on a trusted home network.

Open http://localhost:8000/docs to see the API's Swagger page and http://localhost:8000/openapi.json for the machine-readable description. ZAP can use the second one.

Step 2: Pull ZAP and run a baseline scan

The baseline scan is the gentlest ZAP scan. It crawls the target for a short time and runs only passive rules: it looks at the responses it sees and reports problems, without sending attack payloads. That makes it safe to run often, even in CI.

The container is the official ZAP image. Inside the container, localhost means the container itself, so we use host.docker.internal to reach the API running on your computer. This name works with Docker Desktop on Windows and macOS. On Linux add --add-host=host.docker.internal:host-gateway to the docker run command.

Terminal 2 (create a folder for reports first)
mkdir zap-reports
docker pull ghcr.io/zaproxy/zaproxy:stable
docker run --rm -v "$(pwd)/zap-reports:/zap/wrk:rw" -t ghcr.io/zaproxy/zaproxy:stable \
  zap-baseline.py -t http://host.docker.internal:8000 -r baseline.html

On Windows PowerShell use ${PWD} instead of $(pwd) and put the whole command on one line (replace the trailing backslash). -v shares the folder so the report is saved on your computer; -r names the HTML report. On Linux, if ZAP cannot write the report, run chmod a+w zap-reports.

The first pull downloads a large image, so it may take a while on a slow connection. When the scan finishes (about a minute), ZAP prints a list of rule results and a summary.

Shape of the output (your rules and counts will differ)
PASS: Cookie No HttpOnly Flag [10010]
PASS: Cross-Domain JavaScript Source File Inclusion [10017]
...
WARN-NEW: Non-Storable Content [10049] x 3
WARN-NEW: Storable and Cacheable Content [10049] x 2
...
FAIL-NEW: 0   FAIL-INPROG: 0   WARN-NEW: 2   WARN-INPROG: 0   INFO: 0   IGNORE: 0   PASS: 55

Each line is a rule: PASS means it found nothing, WARN means an alert to review, FAIL means a more serious alert. The script's exit code tells CI the result: 0 success, 1 at least one FAIL, 2 at least one WARN and no FAIL, 3 any other failure. Open zap-reports/baseline.html in your browser for the details.

Step 3: Read an alert

For every alert the report shows: the risk level (High, Medium, Low, Informational), a confidence level, the URL and method, the evidence (the part of the response that triggered it), a description and a suggested solution. Do not treat the list as a verdict. For each alert ask three questions:

1. Is it real? Scanners produce false positives. Look at the evidence. 2. Does it matter here? A missing header matters for a browser page more than for a pure JSON API. 3. What is the fix and who owns it? Often a one-line change.

Alerts you may see on a simple API, and what to do

AlertMeaningTypical fix
Missing Anti-clickjacking HeaderPage could be put inside someone else's frameX-Frame-Options or CSP frame-ancestors
X-Content-Type-Options Header MissingBrowser may guess content typesX-Content-Type-Options: nosniff
Content Security Policy (CSP) Header Not SetNo policy limiting what a page can loadAdd a Content-Security-Policy header
Storable and Cacheable ContentResponses may be cached by shared cachesCache-Control: no-store for private data
Server leaks version informationHeaders reveal software and versionHide or generalise the Server header

Which alerts appear depends on your ZAP version and on your responses; treat this table as examples of how to think, not an exact list.

Step 4: Fix and scan again

Add a small middleware to main.py (the API from the earlier lesson). It adds defensive headers to every response. Restart the API afterwards.

main.py (add below app = FastAPI(...))
@app.middleware("http")
async def add_security_headers(request, call_next):
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["Cache-Control"] = "no-store"
    response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
    return response

FastAPI middleware runs for every request. The CSP here is deliberately strict because this API returns only JSON. Do not copy a strict CSP onto a website that serves pages and scripts without testing it.

Run the baseline scan again with a new report name and compare the two reports. Alerts about those headers should go away. This loop (scan, triage, fix, re-scan) is the real skill. Keeping the result in CI makes sure the fix is not undone by accident.

Step 5: Scan from the OpenAPI description

A crawler finds only what links lead to, and an API has no links. The API scan reads your OpenAPI document and sends requests to every endpoint it describes, which gives far better coverage. Unlike the baseline scan, it can also run active rules that send attack-style input. That is why you must only run it on your own local or test copy, never on production or data you care about.

Terminal 2
docker run --rm -v "$(pwd)/zap-reports:/zap/wrk:rw" -t ghcr.io/zaproxy/zaproxy:stable \
  zap-api-scan.py -t http://host.docker.internal:8000/openapi.json -f openapi -r api-scan.html

-f openapi tells ZAP the format of the definition. Because the API stores orders in memory, restarting it afterwards removes the junk orders ZAP created.

See the traffic with the desktop app

To learn what ZAP sends, install the ZAP desktop app from the official site, set it as the proxy for your browser or run its Automated Scan against your local site and watch the History tab. Seeing the real requests is the best way to understand the alerts.

What scanners cannot find

A scanner does not know your business rules. It cannot tell that customer A should not read customer B's order, because both are valid requests to it. It cannot know that a total should be calculated on the server. Those need test cases written by a person. You can automate them with the same pytest setup you already have:

test_security_basics.py
import httpx

BASE_URL = "http://127.0.0.1:8000"


def test_order_id_is_not_guessable():
    order = httpx.post(
        f"{BASE_URL}/orders",
        json={"customer_name": "Asha", "phone": "9876543210",
              "items": [{"item_id": "espresso", "qty": 1}]},
    ).json()

    # a random UUID, not 1, 2, 3...
    assert len(order["id"]) == 36
    assert not order["id"].isdigit()


def test_error_does_not_leak_internals():
    response = httpx.get(f"{BASE_URL}/orders/not-a-real-id")

    assert response.status_code == 404
    assert "Traceback" not in response.text
    assert "sqlalchemy" not in response.text.lower()


def test_total_cannot_be_set_by_the_client():
    response = httpx.post(
        f"{BASE_URL}/orders",
        json={"customer_name": "Asha", "phone": "9876543210",
              "items": [{"item_id": "espresso", "qty": 1}], "total": 1},
    )

    assert response.json()["total"] == 120

Run with pytest while the API is up. These tests encode security expectations the scanner cannot know: unguessable ids, no leaked internals and a server-side total.

Once the API has login and customers, the most valuable manual test is broken object level authorization: log in as customer A, copy the id of A's order, log in as customer B and request it. The answer must be 403 or 404, never the order. Test the same for updating and cancelling. This comes up again in the Security track.

Prompt · Triage a ZAP alert
You are an application security engineer helping a junior tester.

My app: [describe the app, framework and whether it returns JSON or HTML].

ZAP reported this alert from a scan of my own local test environment:
<alert>
[paste alert name, risk, URL, evidence, description]
</alert>

Explain in plain English: 1) what the problem is, 2) whether it is likely a false positive for my kind of app and how to check, 3) the smallest fix in [framework], 4) how I would write an automated test to make sure it stays fixed.

What to expect: A good answer separates real risk from noise and gives a concrete code or config change. Verify the fix by scanning again; do not apply security changes you do not understand.

How this comes up in interviews

Typical questions: "What is the difference between SAST and DAST?", "What is the OWASP Top 10?", "What is a false positive and how do you handle it?", "How would you test an API for authorization problems?", "Where does security testing fit in CI?". Strong answers mention: DAST tests a running app and SAST reads code; scanners find configuration and common-pattern issues but not business-logic flaws; every alert is triaged, not blindly fixed; the baseline scan is cheap enough to run on every build while deeper scans run on a schedule against a test environment.

Try it: Scan, fix and prove it

1. Remove the security-headers middleware, run the baseline scan and keep before.html. 2. Add the middleware back, scan again and save as after.html. 3. List which alerts disappeared and which remain. For each remaining one, write one sentence: real issue, false positive or accepted risk, and why.

Try it: Write an authorization test (design only)

Imagine the API gets login and each order stores a customer_id. Write, in plain English, five test cases for authorization on GET, PATCH and cancel of an order (who is allowed, who is not, and what status code you expect). Then pick one and write it as a pytest test using two tokens.

Key takeaways

  1. Only scan systems you own or have written permission to test; use intentionally vulnerable labs for practice.
  2. ZAP is a DAST tool: it tests a running app. The baseline scan is passive and safe for frequent use; the API scan is more thorough and active.
  3. Run ZAP from Docker with zap-baseline.py or zap-api-scan.py and save an HTML report with -r.
  4. Triage every alert (real, false positive, or accepted risk) before fixing, then scan again to prove the fix.
  5. Scanners miss business-logic flaws such as broken access control; write explicit tests for them.
  6. Combine DAST with dependency scans and code review (SAST) and run the cheap ones in CI.

Check yourself

Finishing the quiz marks the lesson as done.

1. What is the main difference between DAST and SAST?

2. Why is the ZAP baseline scan safe to run often, even in CI?

3. Customer A can read customer B's order by changing the id in the URL. Will ZAP's scan reliably find this?

4. ZAP reports an alert on your test environment. What should you do first?

5. Inside the ZAP Docker container, why do we use host.docker.internal instead of localhost?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free