Run DAST on staging safely and turn findings into actionable fixes
For developers who need to run dynamic application security testing against a staging environment without breaking data, spamming users, or generating noise. You’ll end with a repeatable scan command, staging guardrails, CI-friendly exit codes, and a triage workflow that turns findings into tickets developers can actually fix.
TL;DR — Run DAST only against a staging URL that is isolated from production side effects, authenticated with a dedicated low-privilege test account, and protected by explicit allowlists/rate limits. The most common failure is scanning a staging app that still sends real email, hits live payment/webhook endpoints, or lacks a stable authenticated session; fix that first, then run the scanner with a fixed context file and fail CI only on high-confidence High/Critical findings. Reading time: ~5 min
Goal
When you finish, you will have a staging environment that can be scanned repeatedly by a DAST tool without touching production systems, plus a command you can run locally or in CI that produces machine-readable results and a short list of findings mapped to concrete fixes.
Prerequisites
- Access to your staging app URL, for example
https://staging.example.com - Permission to change staging app config, ingress/reverse proxy config, and CI pipeline config
- A dedicated staging test account, for example
security-scan@example.test, with the lowest role that still reaches the app areas you care about - Docker Engine 24+ — check with:
docker --version
curl8+ — check with:
curl --version
jq1.6+ — check with:
jq --version
- One DAST scanner available in your environment; this article uses OWASP ZAP in Docker because it is common and scriptable
- The exact hostnames or endpoints that staging is allowed to call during a scan, and the ones it must never call (SMTP, payment gateways, production webhooks, production APIs)
Steps
Step 1: Block real-world side effects from staging
⚠️ If you skip this, a scan can create real orders, send real email, trigger production webhooks, or mutate shared data. Do this before the first scan.
Set explicit staging-only environment variables in your app deployment:
export APP_ENV=staging
export MAILER_TRANSPORT=null
export STRIPE_API_BASE_URL=http://127.0.0.1:9
export WEBHOOK_DELIVERY_ENABLED=false
export SMS_PROVIDER=log
export ALLOW_OUTBOUND_HOSTS="staging.example.com,api.staging.example.com"
If you use iptables on the staging host, block outbound traffic except DNS and explicitly allowed targets:
sudo iptables -A OUTPUT -p tcp --dport 25 -j REJECT
sudo iptables -A OUTPUT -p tcp --dport 465 -j REJECT
sudo iptables -A OUTPUT -p tcp --dport 587 -j REJECT
sudo iptables -A OUTPUT -p tcp -d 0.0.0.0/0 -m string --string "api.stripe.com" --algo bm -j REJECT
Success looks like your app still loads, but test actions no longer send real messages or reach live third parties.
Step 2: Confirm staging is isolated from production integrations
Run these checks from a shell that can reach staging:
curl -sS https://staging.example.com/health | jq .
curl -sS -o /dev/null -w 'smtp=%{remote_ip} code=%{http_code}\n' https://staging.example.com/password-reset
curl -I https://staging.example.com
If the app is behind a login redirect, curl -I will often look like this:
HTTP/2 302
location: /login
cache-control: no-store
set-cookie: session=abc123; HttpOnly; Secure; SameSite=Lax
Success looks like health checks return staging identifiers, and no response headers or logs indicate production domains, production API keys, or real outbound delivery.
Step 3: Create a dedicated authenticated scan context
Create a file named zap.context with your staging scope and auth placeholders:
{
"env": {
"contexts": [
{
"name": "staging",
"urls": ["https://staging.example.com"],
"includePaths": ["https://staging.example.com/.*"],
"excludePaths": ["https://staging.example.com/logout.*", "https://staging.example.com/admin/impersonate.*", "https://staging.example.com/billing/checkout.*"],
"authentication": {
"method": "form",
"loginUrl": "https://staging.example.com/login",
"loginRequestData": "email={%username%}&password={%password%}"
},
"users": [
{
"name": "scanner",
"credentials": {
"username": "security-scan@example.test",
"password": "REPLACE_ME"
}
}
]
}
]
}
}
Success looks like you have one file that defines exactly what is in scope, what is out of scope, and which account the scanner will use.
Step 4: Add rate limiting and scanner allowlisting at the edge
If you use nginx in front of staging, add a dedicated rate limit and optional IP allowlist for the scanner runner:
limit_req_zone $binary_remote_addr zone=dast:10m rate=5r/s;
server {
server_name staging.example.com;
location / {
allow 203.0.113.10;
deny all;
limit_req zone=dast burst=20 nodelay;
proxy_pass http://staging_app;
}
}
Reload nginx:
sudo nginx -t && sudo systemctl reload nginx
Success looks like normal requests from the scanner source IP return app responses, while unexpected sources get 403 Forbidden.
Step 5: Run a baseline scan first
Run ZAP baseline against the staging host and save HTML, JSON, and Markdown reports:
mkdir -p reports
docker run --rm -t \
-v "$PWD:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py \
-t https://staging.example.com \
-J /zap/wrk/reports/zap-baseline.json \
-r /zap/wrk/reports/zap-baseline.html \
-w /zap/wrk/reports/zap-baseline.md \
-m 5 \
-a
Typical output shape:
PASS: URL https://staging.example.com/
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 3
WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 8
FAIL-NEW: 0 WARN-NEW: 2 INFO: 5 IGNORE: 0 PASS: 12
Success looks like report files exist under reports/ and the scan completes without authentication loops or network errors.
Step 6: Run an authenticated active scan with explicit scope
⚠️ Active scanning sends attack payloads and can create or mutate data. Do not point this at production, shared QA, or any environment with live integrations.
Start ZAP as a daemon:
docker run --rm --name zap -u zap -p 8090:8090 -i ghcr.io/zaproxy/zaproxy:stable zap.sh -daemon -host 0.0.0.0 -port 8090 -config api.disablekey=true
Import the context and run spider + active scan from another terminal:
curl "http://127.0.0.1:8090/JSON/context/action/importContext/?contextFile=/zap/wrk/zap.context"
curl "http://127.0.0.1:8090/JSON/spider/action/scanAsUser/?contextName=staging&userName=scanner&url=https://staging.example.com&maxChildren=50&recurse=true"
curl "http://127.0.0.1:8090/JSON/ascan/action/scanAsUser/?contextName=staging&userName=scanner&url=https://staging.example.com&recurse=true&inScopeOnly=true"
Poll progress:
watch -n 5 'curl -s http://127.0.0.1:8090/JSON/ascan/view/status/ | jq -r .status'
Export results:
curl -s "http://127.0.0.1:8090/JSON/core/view/alerts/" > reports/zap-active-alerts.json
jq '.alerts[] | {risk: .risk, name: .alert, url: .url, param: .param}' reports/zap-active-alerts.json
Success looks like the scan reaches 100 and reports/zap-active-alerts.json contains alerts only for https://staging.example.com/... URLs.
Step 7: Fail CI only on actionable severities
Use jq to fail the job only for High or Critical findings after filtering known accepted risks:
jq '[.alerts[] | select((.risk == "High" or .risk == "Critical") and (.alert | IN("Missing Anti-clickjacking Header") | not))] | length' reports/zap-active-alerts.json
Example CI gate:
COUNT=$(jq '[.alerts[] | select((.risk == "High" or .risk == "Critical"))] | length' reports/zap-active-alerts.json)
echo "high_or_critical=$COUNT"
test "$COUNT" -eq 0
Success looks like the command exits 0 when there are no High/Critical findings and exits 1 when there are.
Step 8: Convert findings into developer-fixable tickets
Use this mapping when creating tickets from the JSON report:
| Finding pattern | Ticket title | Fix owner | Evidence to attach |
|---|---|---|---|
| Missing security header | Add <header> on staging and production ingress | platform/backend | response headers from curl -I |
| Reflected XSS | Encode <param> in <route> | app team | vulnerable URL, payload, response snippet |
| SQL injection | Parameterize query in <endpoint> | app team | endpoint, parameter, DB error or timing evidence |
| Auth/session issue | Set cookie flags / CSRF validation on <route> | app team | request/response pair, cookie attributes |
Success looks like each finding has an owner, a route, a reproduction request, and a fix type instead of a vague scanner screenshot.
Verify it works
Run the full sequence and verify all of these:
curl -I https://staging.example.com
jq '[.alerts[] | select(.risk == "High" or .risk == "Critical")] | length' reports/zap-active-alerts.json
Expected end state:
HTTP/2 200
# or HTTP/2 302 with location: /login if auth is required
And for the findings count:
0
If you intentionally left Medium/Low findings open, that is fine; the important proof is that the scan runs against staging only, produces reports, and your CI gate behaves predictably on High/Critical results.
Common pitfalls
Scanning staging that still talks to live providers
Mistake: staging uses real SMTP, payment, webhook, or third-party API credentials.
Symptom: users receive unexpected email/SMS, payment attempts appear in live dashboards, or production webhooks fire during the scan.
Fix: set literal staging env vars to null/log transports and block outbound ports/domains before scanning.
Authentication loop traps the spider
Mistake: the scanner is not logged in, or the login form field names in loginRequestData do not match the app.
Symptom: the report contains only /login, /logout, and redirect responses; scan output stalls with very few URLs.
Fix: update loginRequestData to the exact field names from the form and rerun with the dedicated test account.
Rate limiting too aggressively
Mistake: edge rate limits are lower than the scanner concurrency.
Symptom: many 429 Too Many Requests responses, incomplete crawl, or false negatives.
Fix: raise the nginx burst/rate for the scanner source IP or lower scanner concurrency and rerun.
Out-of-scope destructive routes included
Mistake: active scan includes logout, billing, admin impersonation, or data export routes.
Symptom: the scanner logs itself out, creates junk records, or triggers expensive background jobs.
Fix: add exact regex exclusions in excludePaths and rerun the scan.
Failing CI on noisy Medium/Low alerts
Mistake: every warning breaks the pipeline.
Symptom: developers ignore the job because it is always red for headers or informational checks.
Fix: gate CI only on High/Critical by default and track lower severities in tickets or a scheduled report.
Shared staging data causes misleading results
Mistake: multiple teams or test suites mutate the same staging tenant during the scan.
Symptom: non-reproducible findings, random 404/409 responses, or session invalidation mid-scan.
Fix: run scans against a dedicated staging tenant/database snapshot and reset it before each scheduled scan.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI