API Security in Depth
An OpenAPI spec is a precise, machine-readable map of every endpoint, parameter, and data shape an API exposes — which means an attacker doesn't need to guess at your API's surface the way they'd guess at a web app's; they can just read it. This page is the material Dynamic Analysis in Practice deliberately left for later: the OWASP API Security Top 10, and three testing techniques a generic DAST active scan structurally cannot perform on its own — generating adversarial test cases straight from an OpenAPI, GraphQL, or gRPC contract instead of from crawled links; replaying the identical request as multiple identities to catch authorization bugs that never look like an attack payload; and load-testing the rate limits and abuse controls standing between a legitimate API key and a script running that key ten thousand times a second. None of this replaces SAST, DAST, or SCA. It sits alongside them, aimed squarely at the failure modes that only show up once other programs — not browsers — are the ones sending the requests.
Imagine an apartment building where every door uses the same-shaped key card, and the only thing that separates unit 4B from unit 4C is a small number the front desk trusts you to type in correctly yourself. A regular security guard checks that the building's front door is locked and no window's been jimmied open — that's a lot like a generic web scanner. But the guard has no way of knowing that resident 4B can walk up to the elevator panel, type "4C" instead of "4B", and just let themselves into someone else's apartment — because typing a different number isn't breaking a window, it looks exactly like normal, allowed behavior. API security testing means hiring a second inspector whose entire job is trying every apartment number with every tenant's own card, on purpose, before a real tenant discovers the gap by accident.
Why an API needs a different security test than a generic DAST scan
☺ Like you're 10: A web scanner clicks links to find its way around. An API has no links to click — the only map is the contract someone wrote down, and that contract is also exactly what an attacker reads first.
A generic DAST scanner discovers what to attack by crawling — following <a href> links, submitting forms, driving a headless browser through a rendered UI. An API has none of that. There's no page to render and no link to follow; the only thing that tells a tester (or an attacker) what operations exist is the contract itself — an OpenAPI (Swagger) document, a GraphQL schema exposed through introspection, or a gRPC service description exposed through server reflection. Dynamic Analysis in Practice already covers pointing ZAP's API-scan mode at that same contract and running its usual active-scan payloads — SQL injection, XSS, path traversal — against every documented operation. That's real coverage, and you should still run it. It just isn't the coverage this page is about.
The reason is structural, not a tooling gap that a better scanner will eventually close. A SQL injection payload either breaks the query or it doesn't, and a scanner can tell the difference by watching the response. But a request like GET /orders/4821, sent with a perfectly valid token belonging to a user who happens not to own order 4821, is syntactically flawless. Nothing about the request looks like an attack — no payload, no malformed input, no unexpected character. The only thing wrong with it is who is allowed to send it, and a scanner has no way to know that order 4821 belongs to someone else unless a human tells it so. That's why OWASP maintains a separate project — the OWASP API Security Top 10, distinct from the OWASP Top 10 for web applications — and why the risks that dominate it are almost all shaped like "a well-formed request that shouldn't have been allowed" rather than "a malicious payload that should have been rejected."
The OWASP API Security Top 10 (2023), mapped to what you actually test
☺ Like you're 10: Ten named ways an API gets broken — and for each one, a plain answer to "okay, so what do I actually go do about it."
OWASP periodically revises this list, so treat the numbering below as the 2023 edition — the current one as of this writing — and verify you're citing the current version on the OWASP API Security Project's own page before you put a specific ranking in front of an auditor. What doesn't shift release to release is the shape of the list: five of the first six items are some form of broken authorization, which is the whole reason this page exists.
| Rank | Name | The risk in one line | Tested by |
|---|---|---|---|
| API1:2023 | Broken Object Level Authorization (BOLA) | Swap the ID in the URL or body and reach an object that belongs to someone else | Authorization-matrix testing, below |
| API2:2023 | Broken Authentication | Weak token verification, unrate-limited login/reset flows, or exposed credentials let an attacker become any user | Auth-focused schema fuzzing plus rate limiting on auth endpoints specifically |
| API3:2023 | Broken Object Property Level Authorization (BOPLA) | An endpoint returns properties the caller shouldn't see, or accepts properties the caller shouldn't be able to set | Schema-driven fuzzing with property-level assertions |
| API4:2023 | Unrestricted Resource Consumption | No limits on rate, payload size, response size, or execution cost let one caller exhaust the backend | Rate-limiting & abuse-control testing |
| API5:2023 | Broken Function Level Authorization (BFLA) | A caller reaches an administrative operation because the endpoint checks authentication but never checks role | Authorization-matrix testing across roles, not just object owners |
| API6:2023 | Unrestricted Access to Sensitive Business Flows | Automating a legitimate flow at machine speed breaks a business assumption a human made, with no technical bug involved | Rate-limiting & abuse-control testing at the business-flow level |
| API7:2023 | Server-Side Request Forgery (SSRF) | An API that fetches a caller-supplied URL can be pointed at internal infrastructure instead | Schema-driven fuzzing, seeding URL-typed fields with internal/metadata addresses |
| API8:2023 | Security Misconfiguration | Verbose errors, permissive CORS, unneeded HTTP methods, missing hardening | Generic DAST plus this page's schema-conformance checks |
| API9:2023 | Improper Inventory Management | Old, forgotten, or undocumented versions and hosts stay reachable outside anyone's test scope | Shadow-API discovery — final section |
| API10:2023 | Unsafe Consumption of APIs | The API blindly trusts data, redirects, and responses coming back from a third party it calls | Schema-driven fuzzing of outbound integration points, plus manual review |
Read the ranking as risk prevalence, not a test-order queue. The first five items are almost entirely about who's allowed to do what — object ownership, authentication strength, property-level access, and function-level role checks. A scanner tuned to find injection bugs is structurally blind to all five, because the request that exploits them is syntactically perfect. The only thing wrong is the caller.
Schema-driven fuzzing against an OpenAPI spec
☺ Like you're 10: Instead of guessing what to type into a form, a schema-aware fuzzer reads the rulebook first — then tries everything the rulebook forbids, on purpose, to see if the API actually enforces its own rules.
A generic DAST payload attack mutates whatever traffic the spider happened to generate — it doesn't know a field is supposed to be an integer between 1 and 500, so it can't specifically target that constraint. Schema-driven fuzzing starts from the opposite end: it reads the OpenAPI document's types, formats, enums, and required fields for every operation, and generates test cases that are type-aware in both directions — valid-shaped inputs that should be accepted, and deliberately invalid ones (wrong types, out-of-range numbers, missing required fields, unexpected extra properties) that the API should reject but often doesn't.
Schemathesis is the tool most teams reach for first. It's built on Hypothesis, Python's property-based testing library, so instead of a fixed list of example requests it generates a wide, randomized space of inputs that satisfy — and violate — your schema, then runs a set of built-in checks against every response: does the server ever return a raw 500 for input that should have produced a clean 4xx, does the response actually match the schema it promised, does the status code match what the spec documents. A green run isn't "no vulnerabilities" — it's "the API's real behavior matches the contract it publishes," which is a narrower but still valuable claim, and a persistent mismatch there is frequently the first sign of an API8 misconfiguration or an API9 version nobody's kept in sync.
# Point Schemathesis at the same OpenAPI spec your gateway serves — # it derives every test case from the schema, not from crawled traffic. schemathesis run https://api.staging.example.com/openapi.json \ --base-url https://api.staging.example.com \ --checks all \ --header "Authorization: Bearer $DAST_SERVICE_TOKEN" \ --hypothesis-max-examples=500 \ --report # --checks all runs the built-in suite: not_a_server_error, # status_code_conformance, response_schema_conformance, # response_headers_conformance, negative_data_rejection, and more. # Flag names move between Schemathesis releases — confirm the current # set with `schemathesis run --help` before wiring an exact one into a gate.
Schema-driven fuzzing is also where property-level testing for API3 (BOPLA) belongs, because the schema itself is often the first clue something's wrong. A request body schema that lists role as a client-writable property on a user-profile update endpoint is documenting a mass-assignment bug before a single test even runs:
# PATCH /users/me — the property a negative test is built to catch
requestBody:
content:
application/json:
schema:
type: object
properties:
displayName: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [user, admin] } # should never be client-writable
additionalProperties: false # without this, ANY extra field is silently acceptedadditionalProperties: false stops arbitrary extra fields from being accepted at all, which is worth having — but it doesn't fix the deeper problem: the schema is documenting that role is a bindable input in the first place. A fuzzer sending {"displayName": "x", "role": "admin"} and getting back a 200 with the caller's role actually changed is a mass-assignment finding no matter what the schema says. The durable fix is at the binding layer, not the schema's wording — an explicit allow-list of fields the endpoint is willing to accept from the request body, with anything privileged (role, balance, ownerId, price) set only by server-side logic that a client request can never reach directly.
For deeper coverage, RESTler, from Microsoft Research, does something Schemathesis's single-request model doesn't: it parses the spec for producer-consumer relationships between operations — noticing that POST /orders returns an id that GET /orders/{id} and DELETE /orders/{id} both consume — and builds valid multi-step sequences automatically before fuzzing them, so it can reach application states a single isolated request never would (create an order, then fuzz operations against the order it just created, rather than guessing at an ID that may not exist). That statefulness is exactly what generic DAST spidering also lacks against an API with no HTML to reveal the sequence a real client follows.
Schema-driven fuzzing sends real writes, real deletes, and real malformed input at whatever's behind the base URL you point it at — the same authorization caution that applies to an active DAST scan applies here. Run it only against a staging environment or an isolated, disposable target you're authorized to test, seeded with data you don't mind losing, and never against production.
GraphQL and gRPC: fuzzing without a REST-shaped spec
☺ Like you're 10: A REST API hands you a printed rulebook. GraphQL and gRPC APIs can be asked to recite their own rulebook out loud — if you're allowed to ask, which is exactly the thing worth checking.
REST APIs publish their contract as a document you fetch. GraphQL and gRPC instead let a client ask the server for its own schema at runtime — and whether that's still possible in production is itself a security-relevant question, not just a testing convenience.
A GraphQL server that hasn't disabled introspection will answer a __schema query with every type, query, mutation, and field it exposes — effectively handing an attacker the same map an OpenAPI document would. Tools like graphql-cop check for exactly this kind of exposure alongside other common GraphQL misconfigurations, and Burp's InQL extension can walk a discovered schema to auto-generate a full set of queries and mutations to test — the GraphQL equivalent of feeding a REST spec into a fuzzer. If introspection has been correctly disabled in production, that's a real hardening win, but it's not a complete fix: tools like clairvoyance can reconstruct a usable partial schema by brute-forcing field names against error messages and suggestion hints, which is slower than reading __schema directly but still gets an attacker most of the way there. The deeper risks — BOLA on a field that returns another user's data, unrestricted resource consumption from a deeply nested query — don't go away just because the map got harder to read.
That resource-consumption angle is GraphQL's own distinctive twist on API4/API6: because a single query can request nested, related data arbitrarily deep, a query like "give me this user, their orders, each order's items, each item's reviews, each review's author, that author's orders…" can multiply into an enormous backend cost from one HTTP request that a simple requests-per-second limiter never even notices — one request came in, one request finished. Worse, a caller can alias the same expensive query dozens of times inside a single request (a1: expensiveQuery(...) a2: expensiveQuery(...) …), multiplying cost again while a naive per-request rate limiter still counts it as one hit. Defending against this needs query-depth limits and query-cost analysis, not just request counting — a distinction worth testing for explicitly, not assuming exists.
gRPC's runtime analogue is server reflection — if the reflection service is left enabled outside development, a tool like grpcurl can enumerate every service and method and construct calls against them without ever having seen the .proto file, the same way GraphQL introspection hands over the schema. ghz extends that into load and burst testing against gRPC services specifically, which matters because REST-oriented tools like ZAP, Schemathesis, and k6 mostly don't speak gRPC's binary protocol natively. The underlying lesson is the same across all three protocol styles: use whatever the API's own machine-readable contract is as your map, and specifically test whether a "development convenience" like introspection or reflection has quietly leaked into an environment where it becomes reconnaissance instead.
Authorization-matrix testing: BOLA, BFLA & BOPLA in practice
☺ Like you're 10: The apartment-building drill for real — try every door with every tenant's card, write down what should happen at each one, then check that reality actually matches the list.
An authorization matrix is the systematic form of what the ELI10 analogy at the top of this page described: a table of every (endpoint, method) pair down one side, and every meaningfully distinct actor — the resource's owner, another user in the same tenant, a user in a completely different tenant, an anonymous caller, an elevated role, a service-to-service token with narrower scope — across the top, with the expected status code filled in for each cell. Building it deliberately, rather than testing authorization ad hoc as bugs get reported, is what turns BOLA and BFLA from "things we hope QA notices" into something a pipeline actually checks on every change.
import pytest, requests
BASE = "https://api.staging.example.com"
TOKENS = {
"owner": f"Bearer {OWNER_TOKEN}",
"other_user": f"Bearer {OTHER_USER_TOKEN}", # same tenant, different account
"other_org": f"Bearer {OTHER_ORG_TOKEN}", # a different tenant entirely
"admin": f"Bearer {ADMIN_TOKEN}",
"anon": None,
}
# (method, path template, actor, expected status)
MATRIX = [
("GET", "/orders/{id}", "owner", 200),
("GET", "/orders/{id}", "other_user", 404), # not 403 — see the oracle note below
("GET", "/orders/{id}", "other_org", 404),
("GET", "/orders/{id}", "anon", 401),
("PATCH", "/orders/{id}", "other_user", 404),
("DELETE", "/orders/{id}", "other_user", 404),
("POST", "/admin/refunds", "owner", 403), # BFLA: real auth, wrong role
("POST", "/admin/refunds", "admin", 201),
]
@pytest.mark.parametrize("method,path,actor,expected", MATRIX)
def test_authorization_matrix(method, path, actor, expected, seed_order_id):
url = BASE + path.format(id=seed_order_id)
headers = {"Authorization": TOKENS[actor]} if TOKENS[actor] else {}
resp = requests.request(method, url, headers=headers, timeout=10)
assert resp.status_code == expected, (
f"{actor} {method} {path} -> {resp.status_code}, expected {expected}"
)The seed_order_id fixture creates a real order owned by owner before the matrix runs and tears it down afterward — testing against an ID you're merely guessing exists tells you nothing, and testing against a stale hardcoded ID rots the moment someone cleans up test data. Doing this by hand across every endpoint doesn't scale, which is why Burp's Autorize extension automates the same idea for exploratory testing: it replays every request that passes through the proxy using a second, lower-privileged session, and flags any pair of requests whose responses come back suspiciously identical — a fast way to surface a BOLA candidate before you've written a single line of the matrix above.
Notice the expected status for other_user is 404, not 403. That's deliberate, and it's the detail most teams get wrong first.
Returning 403 for someone else's object confirms the ID is real, which is an information leak in its own right — it turns a blind guess into a confirmed target and makes ID enumeration far more efficient. Returning 404 makes "doesn't exist" and "exists but isn't yours" indistinguishable from outside, at the cost of a little debuggability for the legitimate caller who mistyped an ID — a trade almost every API should take, provided the server's own logs still capture which of the two actually happened for your own audit trail. The same principle extends to BFLA: POST /admin/refunds from an authenticated non-admin is a role check, not an object-ownership check, so 403 is the right answer there — the caller already knows the endpoint exists; hiding that isn't the goal, blocking the action is.
One more discipline matters more than any individual test: the matrix has to be regenerated whenever a new endpoint or role ships, or it audits an API that no longer exists. The reliable way to do that is to derive the endpoint list programmatically from the same OpenAPI spec the schema fuzzer reads, rather than maintaining it by hand in a spreadsheet that drifts the first week nobody updates it — the exact same discipline API9 asks for at the inventory level, applied one level down at the test-suite level.
Rate limiting and abuse controls for machine-to-machine traffic
☺ Like you're 10: A person who gets rate-limited notices and complains. A script that gets rate-limited just keeps hammering — nobody notices a broken limiter until it's already been abused at scale.
Machine-to-machine traffic — partner integrations, service-to-service calls, anything authenticated with an API key or a client-credentials token instead of a human session — breaks the usual assumption behind rate limiting: that abuse looks unusual. A script calling an endpoint a thousand times a second isn't behaving strangely from its own point of view; it's just doing what it was told, as fast as it can. Testing whether the limiter actually holds needs load, not a single crafted request.
- Identify the limiting key, then try to rotate it. Is the limit keyed on IP, API key, user, tenant, or something global? If it's per-key, can an attacker provision new keys or accounts fast enough to outrun the limit per key — a sibling of API6's business-flow abuse, applied to the signup flow itself?
- Test header-trust bypass. A limiter keyed on
X-Forwarded-Forbehind an edge proxy that doesn't overwrite client-supplied values can be defeated by rotating that header on every request, making each one look like it came from a different IP. Fire a burst with a distinct spoofed value per request and see whether the counter resets each time. - Test path-normalization bypass. A naive limiter keyed on the exact path string can treat
/orders,/Orders,/orders/, and/orders?x=1as four separate buckets, each with its own fresh budget. - Test the limiter under real concurrency, not sequential load. This is the one a simple "send 1,000 requests, expect a 429 eventually" test structurally misses. A limit enforced with a check-then-increment that isn't atomic can let every request in a concurrent burst through, because each one checked the counter before any of the others had a chance to update it.
That last case deserves its own example, because it's the one that causes the most real damage: a single-use discount code, a one-per-user signup bonus, or a limited-inventory item claimed through an API is a hard business limit, not just a rate limit, and a race condition there means the limiter can be defeated entirely by simple concurrency rather than by any clever payload.
import http from 'k6/http';
import { check } from 'k6';
// Burst test: fire concurrent requests inside one window and confirm the
// limiter is atomic — not "eventually catches up after the fact".
export const options = {
scenarios: {
burst: {
executor: 'shared-iterations',
vus: 50, // 50 virtual users firing at once
iterations: 50, // one redemption attempt each
maxDuration: '5s',
},
},
};
export default function () {
const res = http.post(
'https://api.staging.example.com/promotions/WELCOME10/redeem',
JSON.stringify({ userId: __VU }),
{ headers: { 'Content-Type': 'application/json',
Authorization: `Bearer ${__ENV.M2M_TOKEN}` } },
);
check(res, { 'request completed': (r) => r.status === 200 || r.status === 409 });
}
// Then assert out-of-band: query the promotions table and confirm exactly
// ONE redemption was recorded, not up to 50. A sequential test at any
// speed would never catch this — there was never a race to lose.k6 and vegeta cover REST and GraphQL load shapes well; ghz is the equivalent for gRPC's binary protocol, which the other two don't speak natively. Beyond raw throughput, two M2M-specific checks are worth adding to the same suite: confirm that client_credentials-grant tokens actually carry least-privilege scopes — a partner integration key that's meant only to read orders shouldn't also be able to issue refunds, a scope-boundary test that's really a BFLA test wearing an M2M costume — and confirm that mutual-TLS-authenticated services validate the full peer certificate chain and its CN/SAN, not merely that a certificate, any certificate, was presented. Both concerns go deeper in Workload Identity & Pipeline IAM and Zero Trust for Pipelines.
A rate limit tuned as one blanket number across the whole API almost always gets it wrong in both directions — too strict for a cheap health-check endpoint, too loose for an expensive search or export operation. Budget the limit to the actual cost of the operation, and for GraphQL specifically, remember that request-counting alone doesn't see aliased batching: a single HTTP request can contain dozens of copies of the same expensive query under different alias names, all counted as one hit by a limiter that only looks at requests per second.
Stand up OWASP crAPI ("Completely Ridiculous API") — a deliberately vulnerable API built specifically to demonstrate this exact Top 10 — in a container you're authorized to attack. Run a Schemathesis pass against its OpenAPI spec first and read what schema-conformance alone surfaces. Then pick two accounts and manually replay a handful of requests from the matrix pattern above, swapping only the token, against an order or vehicle endpoint that belongs to the other account. The gap between what the fuzzer found and what only the second identity revealed is this page.
Wiring it into the pipeline: gates, tools & shadow-API inventory
☺ Like you're 10: These checks need a real, running API to test against, just like DAST does — so they run at the same point in the pipeline, right after something's actually deployed.
All three lanes need a live target, which places them at the same pipeline stage as the DAST job from SAST, DAST & SCA — after a deploy to staging, gated before promotion to production, not on every commit the way SAST and SCA run. There's no reason they can't run in parallel with each other, and with the generic DAST scan, since none of the three depends on the others' output:
# .ci/pipeline.yml — API-specific lanes run after deploy-staging, alongside
# (not instead of) the generic DAST job from the previous chapter
api-schema-fuzz:
stage: api-security
needs: ["deploy-staging"]
script:
- schemathesis run $OPENAPI_URL --base-url $STAGING_URL --checks all --hypothesis-max-examples=500
api-authz-matrix:
stage: api-security
needs: ["deploy-staging"]
script:
- pytest tests/authz_matrix/ --base-url $STAGING_URL
api-rate-limit-smoke:
stage: api-security
needs: ["deploy-staging"]
script:
- k6 run tests/rate-limit-burst.js
allow_failure: true # tune to blocking once thresholds are trusted — same pattern as ZAP's -I flagBeyond the open-source tools named throughout this page, commercial platforms bundle the same three ideas behind one interface: 42Crunch runs a spec-driven security audit against an OpenAPI document and can enforce the result as a runtime firewall in front of the API; StackHawk and APIsec package spec-aware scanning with less pipeline plumbing to hand-write. For the inventory problem specifically — API9 — a spec is only as good as its coverage of what's actually running, and a spec you maintain by hand will always lag reality. Runtime API discovery and protection platforms (Salt Security, Traceable, and Wallarm are the names you'll see most, increasingly folded into broader CNAPP suites like the one covered in CNAPP & the Unified Cloud Security Stack) work the other direction: they observe real traffic at the gateway or in the mesh and flag any endpoint receiving requests that has no matching operation in the declared spec — the technical definition of a shadow API. The durable fix isn't a better spreadsheet; it's a gate that fails when the gateway's live route table and the source-controlled spec disagree, so "improper inventory" stops being something an audit discovers annually and becomes something the pipeline notices immediately.
Common pitfalls specific to API security testing
☺ Like you're 10: Almost none of these are about not knowing the OWASP list — they're about a test that ran, looked thorough, and quietly checked the wrong thing.
| Pitfall | What actually happens | What to do instead |
|---|---|---|
| Treating a passed ZAP API scan as "API security done" | It finds injection and header issues; it structurally cannot find BOLA/BFLA/BOPLA, which need a second identity and ownership knowledge no scanner can infer from a spec alone | Layer schema fuzzing and an authorization matrix on top — generic DAST is necessary, not sufficient |
| Returning 403 for a request blocked by BOLA | Confirms to the caller that the ID exists, turning a blind guess into a confirmed target for enumeration | Return 404 for BOLA-blocked reads; keep the real reason in server-side logs only |
| Hand-maintained authorization-matrix spreadsheet | Drifts silently the moment a new endpoint or role ships, so it keeps auditing yesterday's API | Generate the endpoint list from the OpenAPI spec on every run |
| Disabling GraphQL introspection or gRPC reflection and calling the API "secure" | Still recoverable via schema-guessing tools once an attacker has partial knowledge, and it does nothing for BOLA, BFLA, or resource-consumption risk | Treat it as one hardening item among many, never a substitute for authorization and rate-limit testing |
Rate limiting keyed on a client-supplied header like X-Forwarded-For with no proxy validation | A one-line bypass — rotate the header value per request | Key limits on the validated peer connection or a trusted, provider-set proxy chain header |
| Load-testing rate limits with sequential requests | Passes cleanly because there was never a race, then fails the first time real concurrent traffic hits the same single-use code or limited resource | Test bursts concurrently, especially for any operation with a hard one-time business limit |
Benny: Order API's done — GET, PATCH, DELETE, all wired to auth. Ready for staging.
Timmy: Wired to auth isn't the same as wired to authorization. Did you test it as two different users, not just one logged-in one?
Benny: ...it works when I'm logged in as me.
Rocky the Raccoon: Let me just swap this order ID in the URL real quick. ...Huh. That's my coworker's invoice. With her card number on it.
Foxy: So the token was valid, the request was perfectly formed, nothing about it looked wrong — and it still shouldn't have worked?
Timmy: Exactly why a scanner alone can't catch this one. It has no idea order 4821 belongs to someone else — only a second identity, sent through the same door, tells you that.
Ellie the Elephant: And while we're in there — that admin API key sitting in the .env file you committed "temporarily" three sprints ago? Still live. Still admin-scoped. Rotate it before any of this ships.
Benny: ...I have a lot of apologizing to do to this squad.
Once the schema fuzzer, the authorization matrix, and the rate-limit harness have all reported in, the findings feed the same process as everything else this course scans for: Vulnerability Management & Triage covers turning that output into fixed, tracked, closed tickets rather than a report nobody reads twice. If the hands-on itch from Timmy's drill isn't scratched yet, Part 6 of the capstone lab puts the generic-DAST half of this exact workflow against the pipeline you've been building across the course, and candidates who want to go further than any of this requires — real exploitation technique against APIs, not just scan-and-report — should look at Offensive Security for DevSecOps next.
1. Why can't a generic DAST active scan find a BOLA vulnerability on its own, even though the exact request that exploits it is syntactically well-formed? 2. In an authorization-matrix test, why is returning 404 — rather than 403 — usually the correct response to a BOLA-blocked read, and what's the trade-off? 3. What does Schemathesis check that a hand-written example test wouldn't, and what does RESTler add on top of that? 4. Name two distinct ways a rate limiter can be bypassed that a "send 1,000 requests, check for a 429" test would completely miss. 5. Why does it matter whether GraphQL introspection or gRPC reflection is enabled in production, and why isn't disabling it a complete fix?
Check your answers
- Because the request itself contains no attack payload — no malformed input, no injection string — for a scanner to detect. The only thing wrong is that the caller isn't authorized for that specific object, which requires knowing who owns what, something a spec-reading or traffic-crawling scanner has no way to infer on its own.
- 404 makes "this ID doesn't exist" and "this ID exists but isn't yours" indistinguishable to the caller, denying an attacker the confirmation that a given ID is real and worth continuing to enumerate. The trade-off is reduced debuggability for a legitimate caller who mistyped an ID — acceptable because the server's own logs can still record the real reason internally, just not expose it to the client.
- Schemathesis generates a wide, randomized space of both valid and deliberately invalid inputs straight from the OpenAPI schema's types and constraints, and checks whether the live API's behavior actually matches what it documents — far more coverage than a fixed set of hand-written examples. RESTler adds statefulness: it infers producer-consumer relationships between operations from the spec and builds valid multi-step sequences (create, then fuzz against what was just created) rather than fuzzing each endpoint in isolation.
- Any two of: keying the limit on a client-supplied header like
X-Forwarded-Forthat isn't validated by a trusted proxy, letting the limiter's bucket be keyed on a path variant (trailing slash, case, extra query string) that a normalizer doesn't collapse, or a non-atomic check-then-increment that lets a burst of concurrent requests all pass because none of them saw the others' increment in time — a race a sequential test can never trigger. - Both act as the runtime equivalent of a published spec — introspection or reflection hands an attacker the same map an OpenAPI document would, for free. Disabling it in production is real hardening, but a partial schema can often still be reconstructed by brute-forcing names against error responses, and it does nothing at all for BOLA, BFLA, or resource-consumption risk, which exist regardless of whether the schema is easy or hard to read.