API Security Testing: A Comprehensive Guide for Developers

API Security Testing: A Comprehensive Guide for Developers

APIs are the backbone of modern applications. They are also the primary attack surface. While web application security testing has decades of tooling and methodology behind it, API security testing is often treated as an afterthought — a Postman collection with some happy path tests and nothing more. This guide changes that.

We will cover the OWASP API Security Top 10 with practical test cases for each category, and show how to use Postman, Newman, and OWASP ZAP to systematically find vulnerabilities before attackers do.

The OWASP API Security Top 10

The OWASP API Security Top 10 (2023) identifies the vulnerabilities most commonly exploited in API breaches:

  1. Broken Object Level Authorization (BOLA)
  2. Broken Authentication
  3. Broken Object Property Level Authorization
  4. Unrestricted Resource Consumption
  5. Broken Function Level Authorization (BFLA)
  6. Unrestricted Access to Sensitive Business Flows
  7. Server Side Request Forgery
  8. Security Misconfiguration
  9. Improper Inventory Management
  10. Unsafe Consumption of APIs

Let's test each one.


API1: Broken Object Level Authorization (BOLA)

BOLA — also called Insecure Direct Object Reference (IDOR) — is the most common and impactful API vulnerability. It occurs when an API exposes object identifiers without verifying that the requesting user has access to that specific object.

How to Test for BOLA

Step 1: Create two user accounts. Get the authenticated token for each.

Step 2: Perform a legitimate request as User A to access their own resource:

GET /api/v1/users/1001/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer <user_A_token>

HTTP/1.1 200 OK
Content-Type: application/json

[
  {"id": 5001, "amount": 99.99, "item": "Widget"},
  {"id": 5002, "amount": 149.00, "item": "Gadget"}
]

Step 3: Replay the request with User B's token, still requesting User A's resource:

GET /api/v1/users/1001/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer <user_B_token>

HTTP/1.1 200 OK  ← This means BOLA exists

If User B gets 200 and receives User A's orders, the API has BOLA.

Step 4: Test nested objects and non-sequential IDs:

GET /api/v1/orders/5001 HTTP/1.1
Authorization: Bearer <user_B_token>

GET /api/v1/documents/abc-uuid-here HTTP/1.1
Authorization: Bearer <user_B_token>

UUIDs are not security controls — they are just harder to guess. Test them too.

Automating BOLA Tests with Postman

// Postman test script — checks that cross-user access is denied
pm.test("User B cannot access User A's order", function() {
  pm.response.to.have.status(403);
});

pm.test("Response does not contain User A's data", function() {
  if (pm.response.code === 200) {
    const body = pm.response.json();
    pm.expect(body.userId).to.not.equal(pm.environment.get('userA_id'));
  }
});

API2: Broken Authentication

Authentication vulnerabilities in APIs range from weak tokens to missing rate limiting on login endpoints to JWT implementation flaws.

JWT Security Testing

JWTs are the most common API authentication mechanism. They are also frequently misconfigured.

Algorithm confusion attack — Some libraries accept alg: none:

# Decode the JWT header
<span class="hljs-built_in">echo <span class="hljs-string">"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" <span class="hljs-pipe">| <span class="hljs-built_in">base64 -d
<span class="hljs-comment"># {"alg":"HS256","typ":"JWT"}

<span class="hljs-comment"># Create a none-algorithm token
python3 -c <span class="hljs-string">"
import base64, json

header = base64.urlsafe_b64encode(json.dumps({'alg':'none','typ':'JWT'}).encode()).rstrip(b'=').decode()
payload = base64.urlsafe_b64encode(json.dumps({'sub':'admin','role':'admin'}).encode()).rstrip(b'=').decode()
print(f'{header}.{payload}.')
"

Submit this token as Authorization header. If the API accepts it, it is vulnerable to the none algorithm attack.

Weak secret attack — If the JWT uses HMAC (HS256/384/512), the secret may be weak:

# Crack a JWT secret with hashcat
hashcat -a 0 -m 16500 eyJ... /usr/share/wordlists/rockyou.txt

Test JWT expiration — Take a valid token, wait for it to expire, and retry:

GET /api/v1/profile HTTP/1.1
Authorization: Bearer <expired_token>

HTTP/1.1 401 Unauthorized  ← Correct behavior
HTTP/1.1 200 OK            ← Expired token accepted — vulnerability

Missing signature verification — Modify the JWT payload and see if the server accepts it:

# Take a valid JWT and change the role claim
<span class="hljs-comment"># Original payload: {"sub":"1234","role":"user"}
<span class="hljs-comment"># Modified payload: {"sub":"1234","role":"admin"}
<span class="hljs-comment"># Keep the same signature

If the server accepts the modified payload with the original signature, it is not verifying signatures.

API Key Testing

# Test for API key in URL (should be in header)
GET /api/data?api_key=secret123 HTTP/1.1

# Test predictable API keys
GET /api/data HTTP/1.1
X-API-Key: test
X-API-Key: demo
X-API-Key: 12345678
X-API-Key: aaaaaaaa

# Test for key in logs via Referer
GET /api/data HTTP/1.1
Referer: https://app.example.com/dashboard?api_key=secret123

API3: Broken Object Property Level Authorization

This vulnerability — formerly "Excessive Data Exposure" — occurs when an API returns more properties than the client should see, or allows a client to set properties they should not be able to modify.

Testing for Excessive Data Exposure

GET /api/v1/users/me HTTP/1.1
Authorization: Bearer <token>

HTTP/1.1 200 OK
{
  "id": 1001,
  "email": "user@example.com",
  "name": "John Smith",
  "role": "user",
  "passwordHash": "$2b$10$abc...",     ← Should not be exposed
  "internalNotes": "VIP customer",      ← Should not be exposed
  "stripeCustomerId": "cus_abc123",     ← Should not be exposed
  "loginAttempts": 0                    ← Should not be exposed
}

Testing for Mass Assignment

Mass assignment occurs when an API accepts and applies more fields from a request than it should. Test by adding unexpected fields to POST/PUT/PATCH requests:

PATCH /api/v1/users/me HTTP/1.1
Authorization: Bearer <user_token>
Content-Type: application/json

{
  "name": "John Smith",
  "role": "admin",
  "isVerified": true,
  "creditBalance": 999999,
  "subscriptionPlan": "enterprise"
}

Check the response and your profile — did any of the extra fields get applied? If role was updated to admin, the API has a critical mass assignment vulnerability.

More subtle mass assignment test — try internal fields:

PUT /api/v1/products/100 HTTP/1.1
Authorization: Bearer <vendor_token>
Content-Type: application/json

{
  "name": "Widget",
  "price": 9.99,
  "internalCostPrice": 0.01,
  "approvedByAdmin": true,
  "featuredPriority": 999
}

API4: Unrestricted Resource Consumption

Rate limiting is a critical control that APIs frequently lack. Without it, APIs are vulnerable to brute force, enumeration, DoS, and excessive billing.

Testing Rate Limiting

#!/bin/bash
<span class="hljs-comment"># Test rate limiting on login endpoint

URL=<span class="hljs-string">"https://api.example.com/auth/login"
PAYLOAD=<span class="hljs-string">'{"email":"test@example.com","password":"wrong"}'

<span class="hljs-built_in">echo <span class="hljs-string">"Testing rate limiting..."
<span class="hljs-keyword">for i <span class="hljs-keyword">in $(<span class="hljs-built_in">seq 1 50); <span class="hljs-keyword">do
  STATUS=$(curl -s -o /dev/null -w <span class="hljs-string">"%{http_code}" \
    -X POST <span class="hljs-string">"$URL" \
    -H <span class="hljs-string">"Content-Type: application/json" \
    -d <span class="hljs-string">"$PAYLOAD")
  <span class="hljs-built_in">echo <span class="hljs-string">"Request $i: <span class="hljs-variable">$STATUS"
  
  <span class="hljs-keyword">if [ <span class="hljs-string">"$STATUS" = <span class="hljs-string">"429" ]; <span class="hljs-keyword">then
    <span class="hljs-built_in">echo <span class="hljs-string">"Rate limiting triggered at request $i"
    <span class="hljs-built_in">exit 0
  <span class="hljs-keyword">fi
<span class="hljs-keyword">done

<span class="hljs-built_in">echo <span class="hljs-string">"WARNING: No rate limiting detected after 50 requests"

Testing with Different Identifiers

Rate limits are sometimes applied per IP but not per account (or vice versa). Bypass techniques:

# Add X-Forwarded-For headers to spoof IP
POST /api/auth/login HTTP/1.1
X-Forwarded-For: 1.2.3.4

# Increment the IP on each request to bypass per-IP rate limiting
X-Forwarded-For: 1.2.3.1
X-Forwarded-For: 1.2.3.2
X-Forwarded-For: 1.2.3.3

Also test:

  • X-Real-IP
  • X-Originating-IP
  • True-Client-IP
  • CF-Connecting-IP

API5: Broken Function Level Authorization (BFLA)

BFLA occurs when users can call API functions they should not have access to — typically admin functions.

Testing for BFLA

Test HTTP method escalation:

# Normal user reads a resource
GET /api/v1/users/1001 HTTP/1.1
Authorization: Bearer <user_token>
→ 200 OK

# Try to delete as normal user
DELETE /api/v1/users/1001 HTTP/1.1
Authorization: Bearer <user_token>
→ Should be 403, but may be 200

# Try admin endpoints
GET /api/v1/admin/users HTTP/1.1
Authorization: Bearer <user_token>
→ Should be 403

POST /api/v1/admin/users HTTP/1.1
Authorization: Bearer <user_token>
→ Should be 403

Test path traversal to admin functions:

GET /api/v1/users/me/../admin/dashboard HTTP/1.1
Authorization: Bearer <user_token>

GET /api/v1/user-profile?include=../admin/settings HTTP/1.1
Authorization: Bearer <user_token>

Enumerate hidden endpoints:

# Use a wordlist to find undocumented API endpoints
ffuf -u https://api.example.com/api/v1/FUZZ \
  -w /usr/share/wordlists/SecLists/Discovery/Web-Content/api/api-endpoints.txt \
  -H <span class="hljs-string">"Authorization: Bearer <user_token>" \
  -mc 200,201,204

Testing Authentication with Postman

Postman is the standard tool for API testing. Here is a complete collection structure for authentication testing:

Collection Variables Setup

{
  "variable": [
    {"key": "base_url", "value": "https://api.example.com"},
    {"key": "user_token", "value": ""},
    {"key": "admin_token", "value": ""},
    {"key": "user_id", "value": ""},
    {"key": "admin_id", "value": ""}
  ]
}

Pre-request Script for Token Management

// Automatically refresh token if expired
const tokenExpiry = pm.environment.get('token_expiry');
const now = Date.now();

if (!tokenExpiry || now > parseInt(tokenExpiry)) {
  const loginRequest = {
    url: pm.environment.get('base_url') + '/auth/login',
    method: 'POST',
    header: {'Content-Type': 'application/json'},
    body: {
      mode: 'raw',
      raw: JSON.stringify({
        email: pm.environment.get('test_email'),
        password: pm.environment.get('test_password')
      })
    }
  };

  pm.sendRequest(loginRequest, function(err, res) {
    const token = res.json().token;
    pm.environment.set('user_token', token);
    pm.environment.set('token_expiry', now + 3600000); // 1 hour
  });
}

Security Test Scripts

// Test that unauthenticated requests are rejected
pm.test("Unauthorized access returns 401", function() {
  pm.response.to.have.status(401);
});

// Test that authorization errors return 403 not 404
pm.test("Forbidden access returns 403 not 404", function() {
  pm.response.to.have.status(403);
  // 404 could reveal resource existence to unauthorized users
});

// Test response does not leak sensitive data in errors
pm.test("Error response does not contain stack trace", function() {
  const body = pm.response.text();
  pm.expect(body).to.not.include('at ');  // Stack trace pattern
  pm.expect(body).to.not.include('Exception');
  pm.expect(body).to.not.include('stacktrace');
});

// Test security headers
pm.test("Response has security headers", function() {
  pm.expect(pm.response.headers.get('X-Content-Type-Options')).to.equal('nosniff');
  pm.expect(pm.response.headers.get('X-Frame-Options')).to.exist;
});

Running Tests with Newman in CI

# Install Newman
npm install -g newman newman-reporter-htmlextra

<span class="hljs-comment"># Run collection against staging
newman run api-security-tests.postman_collection.json \
  --environment staging.postman_environment.json \
  --reporters cli,htmlextra \
  --reporter-htmlextra-export reports/security-report.html \
  --bail  <span class="hljs-comment"># Stop on first failure

<span class="hljs-comment"># Run in GitHub Actions
# .github/workflows/api-security.yml
name: API Security Tests
on: [push, pull_request]

jobs:
  security-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Install Newman
        run: npm install -g newman newman-reporter-htmlextra
      
      - name: Run API Security Tests
        run: |
          newman run postman/security-tests.json \
            --env-var "base_url=${{ secrets.STAGING_API_URL }}" \
            --env-var "admin_token=${{ secrets.STAGING_ADMIN_TOKEN }}" \
            --reporters cli,htmlextra \
            --reporter-htmlextra-export reports/report.html
      
      - name: Upload Security Report
        uses: actions/upload-artifact@v3
        if: always()
        with:
          name: security-report
          path: reports/report.html

API Input Validation Testing

APIs often skip input validation because they expect clients to be well-behaved. Test boundary conditions:

Type Juggling

POST /api/v1/orders HTTP/1.1
Content-Type: application/json

{
  "quantity": "100",        // String instead of integer
  "price": true,            // Boolean instead of number
  "productId": null,        // Null
  "discount": -99.99,       // Negative value
  "quantity": 9999999999    // Integer overflow
}

Parameter Pollution

# HTTP parameter pollution
GET /api/v1/users?role=user&role=admin HTTP/1.1

# JSON array injection
POST /api/v1/search HTTP/1.1
{"query": ["legitimate", "'; DROP TABLE users; --"]}

GraphQL-Specific Testing

If the API uses GraphQL, test for:

# Introspection (should be disabled in production)
query {
  __schema {
    types {
      name
      fields {
        name
      }
    }
  }
}

# Query depth attack (DoS)
query {
  user {
    friends {
      friends {
        friends {
          friends {
            name
          }
        }
      }
    }
  }
}

# Alias-based query batching to bypass rate limiting
query {
  r1: login(email:"admin@example.com", password:"password1")
  r2: login(email:"admin@example.com", password:"password2")
  r3: login(email:"admin@example.com", password:"password3")
}

Using OWASP ZAP for API Security Testing

# Generate an OpenAPI/Swagger spec and import into ZAP
<span class="hljs-comment"># ZAP can crawl and test APIs based on their spec

<span class="hljs-comment"># ZAP API scan via command line
docker run -v $(<span class="hljs-built_in">pwd):/zap/wrk/:rw \
  ghcr.io/zaproxy/zaproxy:stable \
  zap-api-scan.py \
  -t https://api.example.com/openapi.json \
  -f openapi \
  -r api-scan-report.html \
  -a  <span class="hljs-comment"># Enable active scan

ZAP OpenAPI Testing Script

from zapv2 import ZAPv2
import time

zap = ZAPv2(apikey='your-api-key')

# Import OpenAPI spec
zap.openapi.import_url(
  'https://api.example.com/openapi.json',
  'https://api.example.com'
)

# Set authorization
zap.replacer.add_rule(
  description='Add Authorization Header',
  enabled=True,
  matchtype='REQ_HEADER',
  matchregex=False,
  matchstring='Authorization',
  replacement='Bearer your-test-token',
  initiators=''
)

# Active scan
scan_id = zap.ascan.scan('https://api.example.com/api')
while int(zap.ascan.status(scan_id)) < 100:
    time.sleep(5)

# Get alerts by risk level
alerts = zap.core.alerts()
high_risk = [a for a in alerts if a['risk'] == 'High']
print(f"High risk findings: {len(high_risk)}")

Summary

API security testing requires a structured approach that goes beyond automated scanning. BOLA/IDOR testing requires two accounts and deliberate cross-account access attempts. JWT attacks require understanding the algorithm and testing for configuration failures. Rate limiting tests require scripting many rapid requests. Mass assignment requires sending every conceivable extra field in every write request.

Build these tests into your Postman collection from day one, run them with Newman in CI, and supplement with ZAP's active scan against your OpenAPI specification. API security cannot be an afterthought — the endpoints you leave untested are the ones attackers will find first.

Read more