QA How-To
GraphQL API Security Testing With ZAP (2026)
Run GraphQL API security testing with ZAP using a local lab, schema imports, authenticated scans, report triage, manual checks, and practical CI gates.
18 min read | 2,564 words
TL;DR
Import the GraphQL schema into ZAP, scan once without credentials and once with a controlled test identity, then review the generated requests and alerts. ZAP adds broad DAST coverage, but you still need explicit tests for object-level authorization, resolver policy, query cost, and abuse controls.
Key Takeaways
- Import the GraphQL schema so ZAP can generate valid operations instead of treating the endpoint as one opaque POST route.
- Run unauthenticated and authenticated scans separately because each identity exposes different resolvers and response paths.
- Use ZAP for injection, transport, header, and information-disclosure signals, then add manual checks for BOLA and business authorization.
- Keep active scans on a local, test, or explicitly authorized target because generated mutations can change data.
- Triage the exact request, evidence, risk, confidence, and reproducibility before turning an alert into a defect.
- Fail CI only on reviewed risk thresholds and retain HTML plus JSON reports as build artifacts.
- Test query depth, aliases, batching, rate limits, and field-level access with dedicated assertions outside the DAST scan.
GraphQL API security testing with ZAP works best when ZAP knows the schema, can reach a disposable test environment, and uses the same authentication context as a real client. ZAP can then generate valid operations, place payloads into GraphQL arguments, inspect responses, and report evidence that a tester can reproduce.
This tutorial builds a small GraphQL API with one intentional broken object-level authorization flaw. You will scan it anonymously, repeat the scan with a bearer token, inspect machine-readable evidence, and add manual checks for risks a dynamic scanner cannot infer. If you need the broader strategy first, read the modern GraphQL API testing guide.
Only actively scan systems you own or have written permission to test. Generated payloads may execute mutations, so keep this workflow away from production.
TL;DR
| Layer | What you do | Why it matters |
|---|---|---|
| Discovery | Import SDL or introspect the endpoint | ZAP learns operations, arguments, and types |
| Anonymous scan | Run the API scan without a token | Finds public attack surface and accidental exposure |
| Authenticated scan | Add a controlled bearer token | Reaches protected queries and mutations |
| Triage | Review request, evidence, risk, and confidence | Converts scanner output into reproducible engineering work |
| Manual assertions | Test BOLA, field access, query cost, and rate limits | Covers authorization and abuse cases without generic signatures |
| CI gate | Fail on reviewed Medium or High findings | Prevents known security regressions without blocking on noise |
The shortest useful command is zap-api-scan.py -t <endpoint> -f graphql, but a serious workflow also preserves reports, separates identities, and verifies business rules.
What You Will Build
By the end, you will have:
- A local GraphQL API on port 4000 with public, protected, and intentionally vulnerable resolvers.
- An unauthenticated ZAP API scan that imports the schema through introspection.
- An authenticated ZAP Automation Framework plan using an
Authorizationheader replacer. - HTML and JSON reports with commands for deterministic alert triage.
- Manual security oracles for BOLA, introspection exposure, and unrestricted query depth.
- A GitHub Actions job that stores evidence and enforces a reviewed risk threshold.
The lab is deliberately small. Its purpose is to make coverage visible.
Prerequisites
Use Node.js 24.18.0 LTS with npm 11, Docker Engine 28.x or Docker Desktop 4.43+, ZAP 2.17.0 through the official stable image, curl 8.x, and jq 1.8.1. GraphQL.js 17.0.2 is pinned in the lab. Newer patch releases should work, but exact pins make the exercise repeatable.
Verify the local tools and the ZAP core version:
node --version
npm --version
docker --version
curl --version | head -1
jq --version
docker pull ghcr.io/zaproxy/zaproxy:stable
docker run --rm ghcr.io/zaproxy/zaproxy:stable zap.sh -version
The final command should print 2.17.0. The stable image receives refreshed add-ons between core releases. Pin its digest when byte-for-byte reproducibility is required.
You should also be comfortable reading GraphQL variables and error responses. The GraphQL API testing fundamentals explain operation structure, while the OWASP API security testing guide supplies the risk vocabulary used during triage.
Step 1: Create a Safe GraphQL Security Lab
Create a new directory and add the following three files. The schema has a circular Product.related field so the ZAP GraphQL add-on can identify schema cycles. The account(id:) resolver intentionally returns another user's record without comparing the requested ID with the authenticated subject.
mkdir graphql-zap-lab
cd graphql-zap-lab
mkdir reports
{
"name": "graphql-zap-lab",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"graphql": "17.0.2"
}
}
Save that as package.json, then save this as schema.graphql:
type Product {
id: ID!
name: String!
priceCents: Int!
related: [Product!]!
}
type User {
id: ID!
displayName: String!
email: String!
}
type Query {
health: String!
product(id: ID!): Product
search(term: String!, limit: Int = 5): [Product!]!
viewer: User!
account(id: ID!): User!
}
type Mutation {
updateDisplayName(name: String!): User!
}
Save the server as server.mjs:
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { buildSchema, graphql, GraphQLError } from 'graphql';
const schema = buildSchema(readFileSync('./schema.graphql', 'utf8'));
const users = [
{ id: 'u1', displayName: 'Alice Tester', email: 'alice@example.test' },
{ id: 'u2', displayName: 'Bob Reviewer', email: 'bob@example.test' }
];
const products = [
{ id: 'p1', name: 'Security Key', priceCents: 3500, related: [] },
{ id: 'p2', name: 'Test Notebook', priceCents: 1200, related: [] }
];
function requireUser(context) {
if (!context.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' }
});
}
return context.user;
}
const rootValue = {
health: () => 'ok',
product: ({ id }) => products.find((item) => item.id === id) ?? null,
search: ({ term, limit = 5 }) => products
.filter((item) => item.name.toLowerCase().includes(term.toLowerCase()))
.slice(0, limit),
viewer: (_args, context) => requireUser(context),
account: ({ id }, context) => {
requireUser(context);
return users.find((user) => user.id === id);
},
updateDisplayName: ({ name }, context) => {
const user = requireUser(context);
user.displayName = name;
return user;
}
};
const server = createServer(async (request, response) => {
if (request.method === 'GET' && request.url === '/health') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ status: 'ok' }));
return;
}
if (request.method !== 'POST' || request.url !== '/graphql') {
response.writeHead(404).end();
return;
}
try {
let rawBody = '';
for await (const chunk of request) rawBody += chunk;
const body = JSON.parse(rawBody);
const token = request.headers.authorization?.replace('Bearer ', '');
const user = token === 'demo-token' ? users[0]
: token === 'other-token' ? users[1]
: null;
const result = await graphql({
schema,
source: body.query,
rootValue,
contextValue: { user },
variableValues: body.variables,
operationName: body.operationName
});
console.log(JSON.stringify({
operationName: body.operationName ?? 'anonymous',
authenticated: Boolean(user)
}));
response.writeHead(200, {
'content-type': 'application/graphql-response+json; charset=utf-8'
});
response.end(JSON.stringify(result));
} catch (error) {
response.writeHead(400, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: error.message }));
}
});
server.listen(4000, '0.0.0.0', () => {
console.log('GraphQL lab listening on http://localhost:4000/graphql');
});
Install and start it in terminal one:
npm install
npm start
Verify: In terminal two, run curl -sS http://localhost:4000/health | jq -e '.status == "ok"'. jq prints true and exits zero. Keep the server running for every later step.
Step 2: Establish Anonymous and Authenticated Baselines
A scanner result is useful only when you know what the endpoint does before scanning. Send one public operation, one protected operation without credentials, and the same protected operation with the test token.
curl -sS http://localhost:4000/graphql \
-H 'content-type: application/json' \
--data '{"query":"query PublicBaseline { health search(term: \"key\") { id name } }"}' | jq .
curl -sS http://localhost:4000/graphql \
-H 'content-type: application/json' \
--data '{"query":"query AnonymousViewer { viewer { id email } }"}' | jq .
curl -sS http://localhost:4000/graphql \
-H 'content-type: application/json' \
-H 'authorization: Bearer demo-token' \
--data '{"query":"query AuthenticatedViewer { viewer { id email } }"}' | jq .
The first response contains health and one product. The second contains an error with extension code UNAUTHENTICATED. The third returns user u1. GraphQL often reports resolver failures inside a successful HTTP 200 response, so an HTTP-only assertion would miss the authentication behavior.
Verify: Run the authenticated request again with jq -e '.data.viewer.id == "u1" and (.errors == null)'. A zero exit code confirms the token reaches the resolver. If this baseline fails, fix networking or authentication before interpreting any ZAP output. For more negative-response patterns, see API error handling and negative testing.
Step 3: Run GraphQL API Security Testing With ZAP Anonymously
Start with no credentials. ZAP's packaged API scan accepts graphql as the format, introspects the endpoint, generates operations, performs passive analysis, and actively injects payloads into discovered inputs. host.docker.internal lets the container reach the host process. The explicit host mapping also supports Linux Docker Engine.
docker run --rm \
--add-host=host.docker.internal:host-gateway \
-v "$PWD/reports:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap-api-scan.py \
-t http://host.docker.internal:4000/graphql \
-f graphql \
-r zap-unauth.html \
-J zap-unauth.json \
-I
-I prevents warning-level findings from failing this learning run. It does not mean the warnings are accepted risks. The command may take several minutes because active rules try multiple payload families against each generated argument. Watch terminal one: requests from this pass log authenticated:false.
ZAP should identify that introspection is enabled and may report the GraphQL implementation or circular schema references as informational findings. Those signals describe exposure and technology; they are not proof of exploitation. The active scanner may also report header or input findings depending on the current monthly add-ons.
Verify: Confirm both reports exist and parse the JSON:
test -s reports/zap-unauth.html
test -s reports/zap-unauth.json
jq -e '.site | type == "array"' reports/zap-unauth.json
All three commands should exit zero. A scan that produces no confirmed vulnerability can still be valid, but an absent site array usually means discovery or connectivity failed.
Step 4: Triage ZAP Findings as Evidence, Not Verdicts
Convert the report into a compact alert inventory. Traditional ZAP JSON stores alerts under each site, with a rule identifier, risk description, confidence, evidence, solution text, and one or more request instances.
jq -r '
.site[]?.alerts[]?
| [.pluginid, .riskdesc, .confidence, .name, (.instances | length)]
| @tsv
' reports/zap-unauth.json | sort -u
For each Medium or High item, open the HTML report and reproduce the exact request outside ZAP. Check whether the payload reached a GraphQL argument, whether the response proves impact, and whether the affected operation is available to the reported identity. Do not file an injection defect solely because a generic server error appeared. Conversely, do not dismiss an informational introspection alert automatically: production policy may explicitly prohibit public schema discovery.
Use this triage order:
- Confirm the target, operation, identity, and request body.
- Read evidence and confidence before the generic description.
- Replay the instance and vary only the suspected payload.
- Separate exploitable behavior from hardening advice.
- Record the smallest reproducible query and the affected resolver owner.
- Map the result to your threat model, not just its default scanner risk.
The ZAP guide for QA engineers explains alert confidence and false-positive handling in more depth.
Verify: Save a deterministic Medium-or-higher count for comparison with later scans:
jq '[.site[]?.alerts[]? | select(.riskcode | tonumber >= 2)] | length' \
reports/zap-unauth.json
Record the integer in your test notes. Counts compare scan runs; they do not measure application security by themselves.
Step 5: Add an Authenticated GraphQL Automation Plan
An anonymous scan cannot exercise viewer, account, or updateDisplayName successfully. Create zap-auth.yaml in the lab root. The replacer adds a fixed test token to requests, the GraphQL job imports the local SDL and generates operations, and the API Policy keeps active rules focused on server APIs.
env:
contexts:
- name: graphql-lab
urls:
- http://host.docker.internal:4000/graphql
includePaths:
- http://host\.docker\.internal:4000/graphql.*
jobs:
- type: replacer
parameters:
deleteAllRules: true
rules:
- description: GraphQL lab bearer token
url: http://host\.docker\.internal:4000/graphql.*
method: POST
matchType: req_header
matchString: Authorization
matchRegex: false
replacementString: Bearer demo-token
- type: graphql
parameters:
endpoint: http://host.docker.internal:4000/graphql
schemaFile: /zap/wrk/schema.graphql
queryGenEnabled: true
- type: passiveScan-wait
parameters:
maxDuration: 5
- type: activeScan
parameters:
context: graphql-lab
url: http://host.docker.internal:4000/graphql
policy: API Policy
maxRuleDurationInMins: 2
maxScanDurationInMins: 8
delayInMs: 20
threadPerHost: 2
maxAlertsPerRule: 10
- type: passiveScan-wait
parameters:
maxDuration: 5
- type: report
parameters:
template: modern
reportDir: /zap/wrk/reports
reportFile: zap-auth.html
reportTitle: Authenticated GraphQL ZAP Scan
displayReport: false
- type: report
parameters:
template: traditional-json
reportDir: /zap/wrk/reports
reportFile: zap-auth.json
reportTitle: Authenticated GraphQL ZAP Scan
displayReport: false
Run the plan. The volume is the entire project this time because ZAP needs both schema.graphql and zap-auth.yaml.
docker run --rm \
--add-host=host.docker.internal:host-gateway \
-v "$PWD:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap.sh -cmd -autorun /zap/wrk/zap-auth.yaml
The GraphQL job is preferable to blind crawling because a GraphQL service usually exposes one URL while its meaningful attack surface lives in operation fields and arguments. If production disables introspection, mount a reviewed SDL or an introspection-response JSON from the build instead of weakening production policy.
Verify: Terminal one should now show entries with authenticated:true. Also run test -s reports/zap-auth.json && jq -e '.site | length > 0' reports/zap-auth.json. Both conditions must pass before comparing authenticated and anonymous findings. For token-specific attack ideas, use the JWT security testing checklist.
Step 6: Automate GraphQL API Security Testing With ZAP in CI
A useful gate distinguishes scan execution failure from security policy failure. Append this job to the end of zap-auth.yaml after both report jobs. It returns an error for High alerts and a warning exit for Medium alerts.
- type: exitStatus
parameters:
errorLevel: High
warnLevel: Medium
okExitValue: 0
errorExitValue: 1
warnExitValue: 2
alwaysRun: true
First verify the policy locally without losing the exit code:
set +e
docker run --rm \
--add-host=host.docker.internal:host-gateway \
-v "$PWD:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap.sh -cmd -autorun /zap/wrk/zap-auth.yaml
scan_status=$?
set -e
echo "ZAP exit status: $scan_status"
test "$scan_status" -le 2
Status 0 means no alert reached either threshold, 1 means at least one error-level condition, and 2 means warnings without errors. Your first controlled baseline may contain accepted findings. Review them, fix genuine defects, and use narrowly scoped alert filters for documented false positives rather than lowering every threshold.
A minimal .github/workflows/graphql-zap.yml can run the lab and retain evidence even when the scan fails:
name: graphql-zap
on: [pull_request]
jobs:
dast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24.18.0
cache: npm
- run: npm ci
- name: Start GraphQL test target
run: |
nohup npm start > server.log 2>&1 &
for attempt in {1..30}; do
curl -fsS http://localhost:4000/health && break
sleep 1
done
curl -fsS http://localhost:4000/health
- name: Run authenticated ZAP plan
id: zap
continue-on-error: true
run: |
docker run --rm \
--add-host=host.docker.internal:host-gateway \
-v "$PWD:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap.sh -cmd -autorun /zap/wrk/zap-auth.yaml
- uses: actions/upload-artifact@v4
if: always()
with:
name: graphql-zap-reports
path: |
reports/
server.log
- name: Enforce ZAP result
if: steps.zap.outcome == 'failure'
run: exit 1
In a real repository, install and start its existing API instead of the tutorial lab. Pin the ZAP image by digest after validating add-on updates, use a short-lived CI identity, and keep tokens in the CI secret store.
Verify: Open a test pull request and confirm the workflow uploads graphql-zap-reports whether the scan passes or fails. Download zap-auth.json and run the Step 4 jq inventory against it.
Step 7: Add GraphQL-Specific Security Oracles
ZAP sees payload behavior, but it does not know that user u1 must never read user u2. Prove the intentional BOLA flaw with an explicit authorization assertion:
response=$(curl -sS http://localhost:4000/graphql \
-H 'content-type: application/json' \
-H 'authorization: Bearer demo-token' \
--data '{"query":"query OtherAccount { account(id: \"u2\") { id email } }"}')
echo "$response" | jq .
if echo "$response" | jq -e '.data.account.id == "u2"' >/dev/null; then
echo 'FAIL: u1 can read u2 through account(id)'
exit 1
fi
The script intentionally exits 1 against the vulnerable lab. Fix the resolver by retaining const user = requireUser(context), rejecting user.id !== id with a FORBIDDEN GraphQLError, and returning only the authenticated user. Rerun the same oracle; it should exit zero after .data.account is no longer u2. This is a business authorization test, not a signature ZAP can derive from schema types.
Next, check whether production policy allows introspection. The lab intentionally returns the schema:
curl -sS http://localhost:4000/graphql \
-H 'content-type: application/json' \
--data '{"query":"query IntrospectionPolicy { __schema { queryType { name } } }"}' \
| jq -e '.data.__schema.queryType.name == "Query"'
Treat that result according to environment policy. Disabling introspection is defense in depth, not a substitute for resolver authorization. Also test depth and cost controls with a valid nested query, alias fan-out, and variables near configured limits. The lab accepts deep related selections because it has no complexity rule; a hardened API should reject over-budget operations before expensive resolver work. The GraphQL query complexity security tutorial shows how to turn that expectation into measurable tests.
Finally, exercise batch limits, pagination caps, mutation authorization, and rate limits with deterministic API tests. The GraphQL batched request examples cover array payloads and alias-based batching that a standard active scan does not model as abuse.
Verify: Keep each oracle in the API test suite with a named security requirement. A correct BOLA test fails before the resolver fix and passes after it, while the introspection assertion matches the policy for the deployed environment.
ZAP Coverage Versus GraphQL-Specific Tests
Use the tools as complementary layers, not substitutes.
| Risk | ZAP contribution | Dedicated test contribution |
|---|---|---|
| Injection in string or ID arguments | Inserts active-scan payloads into generated operations | Uses domain-valid payloads and verifies data-layer impact |
| Security headers and information leakage | Passively inspects every observed response | Asserts environment-specific header and error policies |
| Public introspection and engine fingerprinting | Raises GraphQL informational alerts | Decides whether exposure violates deployment policy |
| BOLA and field authorization | Cannot infer ownership from an ID argument |
Compares identities, objects, roles, and sensitive fields |
| Query depth and cost | Exposes generated operations and schema cycles | Verifies rejection thresholds and resource budgets |
| Alias or batch abuse | May observe individual requests | Sends controlled fan-out and checks request accounting |
| Stateful mutation abuse | Can inject into discovered mutations | Validates workflow order, replay, idempotency, and side effects |
A clean ZAP report therefore means no configured scanner rule produced an alert in the scanned surface. It does not certify that every resolver enforces tenant, role, ownership, or workflow constraints.
Troubleshooting
Problem: ZAP reports Connection refused for localhost:4000 -> The container's localhost is the container itself. Use host.docker.internal, retain --add-host=host.docker.internal:host-gateway, bind the lab to 0.0.0.0, and verify /health from the host first.
Problem: The GraphQL import creates no operations -> Confirm the endpoint accepts POST introspection, or supply schemaFile with an SDL readable inside /zap/wrk. Check that the endpoint includes scheme, host, port, and path. Validate the plan with zap.sh -cmd -autocheck /zap/wrk/zap-auth.yaml before a full run.
Problem: Protected resolvers still return UNAUTHENTICATED -> Inspect terminal one for authenticated:false. Ensure the replacer appears before the GraphQL job, uses matchType: req_header, spells Authorization correctly, matches the endpoint URL, and sends a token that has not expired.
Problem: The plan exits 2 even though the HTML looks acceptable -> Exit 2 represents the configured warning state. Inventory the JSON, review each Medium alert, then fix it or add a documented narrow filter. Do not hide all warnings with a global ignore merely to make CI green.
Problem: Active scanning changes test data -> GraphQL schema import can discover mutations, and active payloads may invoke them. Scan a resettable environment with a limited identity, seed before the run, and destroy or restore the data afterward. Exclude dangerous operations when the environment cannot tolerate them.
Problem: The authenticated report has fewer findings than the anonymous report -> Counts vary with responses, deduplication, and reachable paths. Compare operation coverage and exact alert instances, not only totals. Confirm the token has intended roles and that authentication failures are not being returned as HTTP 200 GraphQL errors for every generated query.
Interview Questions and Answers
The structured interviewQnA field below contains seven model answers on schema import, authenticated coverage, BOLA, injection triage, CI policy, and the limits of DAST. Use them to explain why ZAP and domain-specific security assertions belong in the same test strategy.
Best Practices
- Scan only authorized non-production targets with resettable state.
- Import a build artifact when introspection is disabled, rather than enabling it solely for DAST.
- Create one scan context per role or tenant boundary instead of using an administrator token everywhere.
- Store short-lived credentials in secret management and prevent reports from capturing reusable tokens.
- Keep GraphQL and ZAP versions visible in logs so changes in generated operations or rules are explainable.
- Review mutations before scanning and seed, snapshot, or restore affected data.
- Pair every scanner pass with explicit authorization and resource-consumption tests.
- Treat alert counts as triage inputs, never as a security score.
Where To Go Next
Expand the lab in three directions. Add role and tenant matrices from the GraphQL security testing foundation. Add JWT expiry, audience, issuer, and privilege checks from the JWT security testing guide. Then model depth, aliases, lists, and resolver cost with the query complexity security tutorial.
For broader DAST practice, compare this workflow with OWASP ZAP testing for QA and the OWASP API security testing guide. Practice explaining the division between scanner coverage and business assertions in QAJobFit practice interviews, or upload a security-focused resume for analysis in the QAJobFit resume workspace.
Conclusion
GraphQL API security testing with ZAP is most effective as a schema-aware DAST layer inside a larger API security strategy. Import the schema, scan the public surface, repeat with carefully scoped identities, preserve evidence, and make the CI threshold reflect reviewed engineering policy.
Do not stop at the scanner report. Keep BOLA, field authorization, query cost, batching, rate limits, and mutation workflows as explicit automated tests. That combination catches both generic implementation weaknesses and the GraphQL business-logic failures that matter most.
Interview Questions and Answers
How would you design a GraphQL security test strategy around ZAP?
I would import a versioned schema, run separate anonymous and role-specific scans, and preserve JSON plus HTML evidence. I would then add deterministic tests for object and field authorization, query cost, batching, rate limits, and mutation workflows because ZAP cannot infer those business rules. CI would fail only on reviewed thresholds and would always upload reports.
What is the security value of ZAP's GraphQL schema import?
The endpoint URL alone hides the real attack surface behind one POST route. Schema import exposes valid queries, mutations, arguments, input types, and nested selections so ZAP can generate accepted operations. That improves both Sites tree coverage and payload placement.
How do you validate an authenticated ZAP scan actually used the intended identity?
I first prove the token with a direct protected query. During the scan I inspect application or gateway logs for the subject, role, and operation, then verify protected operations appear in ZAP's Sites tree or report. Alert counts alone cannot prove authenticated coverage.
How do you triage a possible injection alert in a GraphQL argument?
I replay the exact operation, variable values, headers, and identity from the alert instance. Then I compare a benign value with the suspected payload and look for stable evidence of data-layer impact rather than a generic GraphQL error. I record the smallest reproducer, affected resolver, confidence, and environment before filing the defect.
Why is a clean ZAP report insufficient for GraphQL security sign-off?
A clean report only says enabled scanner rules found no alert in observed traffic. It does not prove tenant isolation, ownership checks, field-level authorization, resolver cost limits, replay protection, or workflow rules. Those controls need identities, domain data, and expected outcomes supplied by dedicated tests.
How would you test broken object-level authorization in GraphQL?
I authenticate as one low-privilege user, query or mutate an object owned by another user, and assert a neutral denial with no sensitive partial data. I repeat across aliases, global IDs, nested relationships, batch forms, and every role transition. The oracle checks policy, not merely an HTTP status.
How do you prevent a DAST gate from becoming noisy and ignored?
I pin the environment and scanner inputs, start from a reviewed baseline, and make each accepted exception narrow, owned, and time-limited. Reports are retained for reproduction, execution failures are distinguished from findings, and new Medium or High alerts receive service-level triage instead of an automatic blanket suppression.
Frequently Asked Questions
Can OWASP ZAP scan a GraphQL API?
Yes. ZAP's GraphQL add-on can import SDL, an introspection response, or introspect an endpoint, generate operations, and provide GraphQL-aware active-scan input vectors. The packaged API scan uses `-f graphql` for this workflow.
Should I enable GraphQL introspection so ZAP can scan production?
Do not change production policy merely to accommodate a scanner. Export a reviewed schema artifact or introspection-response JSON during the build and import it into ZAP while scanning an authorized test environment.
How do I add a bearer token to a ZAP GraphQL scan?
Use an Automation Framework replacer job before schema import, match the `Authorization` request header, and set the replacement to a short-lived test token. Confirm server logs show authenticated requests before trusting coverage.
Will ZAP detect GraphQL BOLA or IDOR vulnerabilities?
Not reliably, because ZAP cannot infer which object IDs belong to which identity. Add a purpose-built test that authenticates as user A, requests user B's object, and asserts denial plus absence of sensitive fields.
Is it safe to run a ZAP active scan against a GraphQL endpoint?
It is safe only on a system you are authorized to attack and whose data can tolerate generated queries and mutations. Prefer a disposable environment, a least-privilege identity, seeded data, and an automatic reset after the scan.
What ZAP exit code should fail a CI pipeline?
Define the policy explicitly with the Automation Framework exitStatus job. A practical starting point is error on High and warning on Medium, followed by a final CI step that enforces the result after reports are uploaded.
Why does a GraphQL authentication failure still return HTTP 200?
GraphQL can represent resolver failures in the response `errors` array while the HTTP exchange itself succeeds. Security assertions must inspect `errors`, extension codes, and protected data, not just the status code.