6 OWASP API Patterns
Every pattern maps to a 2023 OWASP API Top-10 category. Read the vulnerability, see a detection probe in three tools (curl, Playwright, Schemathesis), then note the mitigation you'd expect in a fixed system.
1. Server-Side Request Forgery (SSRF)
OWASP API7:2023 โ Server-Side Request Forgery
Vulnerability: The API accepts a URL parameter and fetches it server-side without validating the host. An attacker points it at 169.254.169.254 (AWS metadata), localhost, or an internal service.
Detection: Probe with a callback server (e.g. webhook.site). If the server fetches your URL, SSRF is confirmed. Escalate to cloud metadata endpoints on AWS, GCP, Azure.
# Probe: does the server fetch arbitrary URLs?
curl -X POST https://api.example.com/webhooks/register \
-H 'Content-Type: application/json' \
-d '{"callback_url": "http://YOUR_COLLABORATOR.oast.live/ssrf-probe"}'
# Escalate: cloud metadata
curl -X POST https://api.example.com/webhooks/register \
-d '{"callback_url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}'test('detect SSRF via outbound probe', async ({ request }) => {
const probeUrl = 'http://YOUR_COLLABORATOR.oast.live/ssrf-${Date.now()}';
await request.post('/api/webhooks/register', {
data: { callback_url: probeUrl },
});
// Poll collaborator server for incoming hit
const hit = await pollCollaborator(probeUrl, { timeoutMs: 10_000 });
expect(hit, 'Expected callback_url to be rejected, but server fetched it').toBeNull();
});# schemathesis run with custom check
schemathesis run openapi.yaml \
--checks=all \
--hypothesis-seed=42 \
--header 'Authorization: Bearer $TOKEN' \
--base-url https://api.example.com
# Add a custom check to flag any response that dereferences a URL param
# docs: https://schemathesis.readthedocs.io/en/stable/customization.htmlMitigation: Allow-list outbound hosts. Block RFC1918 ranges (10/8, 172.16/12, 192.168/16), link-local (169.254/16), and loopback. Resolve DNS once and pin the IP to prevent rebinding.
2. Insecure Direct Object Reference (IDOR)
OWASP API1:2023 โ Broken Object Level Authorization
Vulnerability: GET /orders/1234 returns another user's order because the server trusts the path parameter without checking ownership. Changing the ID in the URL reveals unauthorised data.
Detection: Create two users. Log in as user A, grab one of A's object IDs, then try to fetch / modify it while authenticated as user B. A 200 response is the bug.
# Enumerate: does /orders/{id} leak cross-tenant data?
TOKEN_A=... # user A's session
TOKEN_B=... # user B's session
# User A creates an order
OID=$(curl -s -X POST /api/orders -H "Authorization: Bearer $TOKEN_A" \
-d '{"item":"x"}' | jq -r .id)
# User B should get 404 or 403 โ not 200
curl -i /api/orders/$OID -H "Authorization: Bearer $TOKEN_B"test('IDOR on GET /orders/:id', async ({ request }) => {
const tokenA = await login('alice@example.com');
const tokenB = await login('bob@example.com');
const create = await request.post('/api/orders', {
headers: { Authorization: `Bearer ${tokenA}` },
data: { item: 'laptop' },
});
const { id } = await create.json();
const res = await request.get(`/api/orders/${id}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
// Fail loudly if Bob can read Alice's order
expect([403, 404]).toContain(res.status());
});# Auth matrix test: each endpoint ร each token โ expected status
st run openapi.yaml \
--auth "alice:pwd" \
--auth "bob:pwd" \
--checks response_schema_conformance,not_a_server_error \
--hypothesis-database .hypothesisMitigation: Check ownership on every request: SELECT * FROM orders WHERE id = ? AND owner_id = ?. Never trust client-supplied IDs alone. Use opaque UUIDs instead of sequential integers to reduce enumerability.
3. Mass Assignment
OWASP API6:2023 โ Unrestricted Access to Sensitive Business Flows
Vulnerability: POST /users accepts arbitrary JSON fields and maps them directly to the model. An attacker adds {"role": "admin"} and escalates privileges.
Detection: Add unexpected fields to request bodies โ role, is_admin, balance, verified โ and check if the server accepts them. 200 with the field echoed back is a strong signal.
# Inject admin elevation field
curl -X POST /api/users \
-H 'Content-Type: application/json' \
-d '{
"email": "attacker@evil.com",
"password": "x",
"role": "admin",
"is_verified": true,
"account_balance": 999999
}'
# If GET /api/users/:id returns role=admin, the field was acceptedtest('mass assignment on POST /users', async ({ request }) => {
const res = await request.post('/api/users', {
data: {
email: 'probe@test.dev',
password: 'p4ssw0rd!',
role: 'admin', // should be ignored
is_verified: true, // should be ignored
account_balance: 999_999, // should be ignored
},
});
const { id } = await res.json();
const fetched = await request.get(`/api/users/${id}`).then(r => r.json());
expect(fetched.role).toBe('user'); // default, not admin
expect(fetched.is_verified).toBe(false); // requires email flow
expect(fetched.account_balance).toBe(0); // not user-controlled
});# stateful testing: schemathesis generates field variants
st run openapi.yaml \
--hypothesis-max-examples=500 \
--checks response_schema_conformance \
--data-generation-method=negative
# negative generation attempts to violate the schema โ catches extra-field acceptanceMitigation: Use explicit allow-list on each endpoint: Zod schema or DTO that only permits fields the endpoint documents. Reject unknown keys (strict mode) rather than silently dropping them โ attackers probe for which mode you use.
4. JWT algorithm confusion / none
OWASP API2:2023 โ Broken Authentication
Vulnerability: Server accepts JWTs with {"alg":"none"} or switches RS256 verification to HS256 (using the public key as HMAC secret). Either lets an attacker forge tokens.
Detection: Take a valid token, tamper the header to {"alg":"none"} and strip the signature. If the server accepts it, you have full authentication bypass.
# Decode a valid token
TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.abc123"
# Forge alg:none variant
HEADER='eyJhbGciOiJub25lIn0' # {"alg":"none"}
PAYLOAD='eyJzdWIiOiJhZG1pbiIsImlhdCI6MTd9' # {"sub":"admin","iat":17}
FORGED="$HEADER.$PAYLOAD."
# If this returns 200, your auth is broken
curl -i /api/me -H "Authorization: Bearer $FORGED"test('reject alg:none JWT', async ({ request }) => {
const header = Buffer.from('{"alg":"none"}').toString('base64url');
const payload = Buffer.from('{"sub":"admin"}').toString('base64url');
const forged = `${header}.${payload}.`;
const res = await request.get('/api/me', {
headers: { Authorization: `Bearer ${forged}` },
});
expect(res.status()).toBe(401);
});# Use the built-in auth-fuzzing check
st run openapi.yaml \
--checks=all \
--auth "bearer:VALID_TOKEN" \
# schemathesis will probe with tampered tokens automatically
--hypothesis-max-examples=200Mitigation: Pin the algorithm on the verifier side: jwt.verify(token, key, { algorithms: ['RS256'] }). Never accept 'none'. Separate the signing key from the key used to verify. Use JWKS with kid to rotate keys.
5. Timing attacks on login
OWASP API10:2023 โ Unsafe Consumption of APIs
Vulnerability: Login responds faster for "user not found" than for "wrong password" because the bcrypt check is skipped on unknown emails. An attacker enumerates valid users by measuring response time.
Detection: Send many login attempts with known-bad passwords, split between known valid emails and random ones. Measure p50/p95. A significant gap (> 50 ms) leaks email validity.
# Time known-valid vs random email logins
for email in alice@real.dev random$(date +%s)@test.dev; do
time curl -sS -X POST /api/login \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$email\",\"password\":\"wrong\"}" > /dev/null
done
# If 'alice' takes 200 ms but random takes 20 ms โ timing oracletest('login timing does not leak user existence', async ({ request }) => {
const samples = 30;
const known: number[] = [];
const random: number[] = [];
for (let i = 0; i < samples; i++) {
const tKnown = performance.now();
await request.post('/api/login', { data: { email: 'alice@real.dev', password: 'bad' } });
known.push(performance.now() - tKnown);
const tRandom = performance.now();
await request.post('/api/login', { data: { email: `r${i}@test.dev`, password: 'bad' } });
random.push(performance.now() - tRandom);
}
const median = (a: number[]) => a.sort((x, y) => x - y)[Math.floor(a.length / 2)];
const delta = Math.abs(median(known) - median(random));
expect(delta, 'Timing delta reveals valid email').toBeLessThan(30);
});# Not schemathesis's strong suit โ custom script is better here.
# Use hypothesis + statistics library for rigorous timing comparison.Mitigation: Always run password hashing (bcrypt/argon2) on a dummy hash when the user is not found โ identical work, identical latency. Rate-limit login and alert on enumeration bursts.
6. Rate-limit bypass
OWASP API4:2023 โ Unrestricted Resource Consumption
Vulnerability: Rate limits are keyed on IP alone. Rotating through a proxy pool, adding X-Forwarded-For, or varying casing in the endpoint path bypasses the limiter and enables credential stuffing.
Detection: Issue 1000 login attempts in a minute. Vary X-Forwarded-For, case in path (/login vs /Login), trailing slashes, and query noise. Count how many actually trip the 429.
# Baseline: how many before 429?
for i in {1..200}; do
curl -sS -o /dev/null -w '%{http_code}\n' /api/login -d '{...}'
done | sort | uniq -c
# Bypass probe: spoof X-Forwarded-For
for i in {1..200}; do
IP="10.0.$((RANDOM % 255)).$((RANDOM % 255))"
curl -sS -o /dev/null -w '%{http_code}\n' /api/login \
-H "X-Forwarded-For: $IP" -d '{...}'
done | sort | uniq -ctest('rate-limit cannot be bypassed via XFF spoof', async ({ request }) => {
const codes: number[] = [];
for (let i = 0; i < 200; i++) {
const res = await request.post('/api/login', {
headers: { 'X-Forwarded-For': `10.0.${i}.${i}` },
data: { email: 't@t', password: 'x' },
});
codes.push(res.status());
}
const blocked = codes.filter((c) => c === 429).length;
expect(blocked, 'Spoofed XFF should not reset the limiter').toBeGreaterThan(150);
});# Use fuzzing to discover path variants the limiter misses
st run openapi.yaml \
--checks not_a_server_error \
--hypothesis-max-examples=1000 \
--header 'X-Forwarded-For: {random_ip}'Mitigation: Key rate limits on (authenticated user ID | resolved client IP from a trusted proxy header) โ never on raw user-controlled headers. Normalise the path (lowercase, strip trailing slash) before keying. Use a sliding window instead of a fixed window to resist burst at boundaries.