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:
- Broken Object Level Authorization (BOLA)
- Broken Authentication
- Broken Object Property Level Authorization
- Unrestricted Resource Consumption
- Broken Function Level Authorization (BFLA)
- Unrestricted Access to Sensitive Business Flows
- Server Side Request Forgery
- Security Misconfiguration
- Improper Inventory Management
- 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 existsIf 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.txtTest 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 — vulnerabilityMissing 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 signatureIf 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=secret123API3: 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.3Also test:
X-Real-IPX-Originating-IPTrue-Client-IPCF-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 403Test 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,204Testing 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.htmlAPI 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 scanZAP 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.