Why APIs Are the #1 Attack Surface in 2024–2025
APIs have become the dominant target for attackers — not because they are inherently weaker than web UIs, but because they are more exposed, more numerous, and often tested far less rigorously than the user-facing application. Every mobile app, every third-party integration, every internal microservice exposes an API, and those APIs frequently lack the same care that goes into securing the front-end experience.
Unlike a web page, an API has no visual security layer. There is no CAPTCHA to slow down a script, no UI quirk that alerts the user something is wrong. An attacker can send thousands of crafted requests per second directly to an API endpoint without any browser involved. Salt Security's State of API Security report found that API attacks grew by over 400% in 2023, and the average organization now has hundreds of APIs — many of which are unknown to the security team (shadow APIs).
For QA engineers, this creates a critical responsibility: every API endpoint you test functionally must also be tested for the most common security misconfigurations. This guide gives you the framework to do exactly that.
API Security Architecture Diagram
(JWT / OAuth)
(429 on breach)
(schema / type)
Security layers that QA tests must probe individually
OWASP API Security Top 10 (2023)
OWASP maintains a separate Top 10 list specifically for APIs, distinct from the general OWASP Top 10. Here is what each category means for QA testing.
API1 — Broken Object Level Authorization (BOLA / IDOR): The most common API vulnerability. The API does not verify that the requesting user is authorized to access the specific object they are requesting. Test by swapping object IDs between users.
API2 — Broken Authentication: Weak or missing authentication mechanisms — missing token validation, accepting expired tokens, or no token required at all. Test every endpoint without an auth header and with an expired/malformed token.
API3 — Broken Object Property Level Authorization: The API exposes more object properties in the response than the user should see, or allows writing to properties the user should not control (mass assignment). Test by sending extra properties in request bodies and inspecting response bodies for over-exposed fields.
API4 — Unrestricted Resource Consumption: No limits on how many resources a single client can consume — no pagination limits, no file size limits, no request size limits, no rate limits. Test by sending very large payloads and making rapid consecutive requests.
API5 — Broken Function Level Authorization (BFLA): Users can access administrative functions by calling the API directly, bypassing UI-level restrictions. Test by calling admin API functions as a standard user.
API6 — Unrestricted Sensitive Business Flows: Business-logic flows that can be abused by automation — bulk account creation, mass coupon redemption, automated scalping. Test by automating normally-human-paced flows at machine speed.
API7 — Server-Side Request Forgery (SSRF): The API fetches external resources based on user-supplied URLs. Test by supplying internal/metadata URLs as parameter values.
API8 — Security Misconfiguration: Default settings, unnecessary endpoints, permissive CORS, verbose error messages. Test default paths, trigger errors, and check response headers.
API9 — Improper Inventory Management: Shadow APIs, deprecated API versions still active, undocumented endpoints. Test by fuzzing version numbers (/api/v0/, /api/v3/) and checking for older versions still responding.
API10 — Unsafe Consumption of APIs: The application blindly trusts data from third-party APIs without validation, then passes it to users or databases. Verify that the application sanitizes and validates all data received from external APIs before processing.
Authentication Testing
Authentication is the first layer an attacker will probe. Every API endpoint must be tested for all authentication failure states.
| Test Case | Request Condition | Expected HTTP Status | Expected Body |
|---|---|---|---|
| Missing auth header | No Authorization header sent |
401 Unauthorized | Generic error, no data |
| Invalid token format | Authorization: Bearer INVALID_TOKEN |
401 Unauthorized | Token validation error |
| Expired token | Valid JWT with exp in the past |
401 Unauthorized | Token expired message |
| Token for wrong user | User A's token accessing User B's endpoint | 403 Forbidden | Access denied, no data leaked |
| Algorithm confusion (none) | JWT with "alg": "none" and no signature |
401 Unauthorized | Rejected, not accepted |
Authorization Testing — BOLA / IDOR
BOLA (Broken Object Level Authorization) — also called IDOR (Insecure Direct Object Reference) — is the most pervasive API security vulnerability. The test is simple: can User A access User B's data?
# BOLA test with Python requests
import requests
BASE_URL = "https://api.example.com"
# Log in as User A
user_a_token = get_token("user_a@example.com", "password_a")
# Log in as User B
user_b_token = get_token("user_b@example.com", "password_b")
# User B creates a resource — note the returned ID
response = requests.post(
f"{BASE_URL}/api/orders",
headers={"Authorization": f"Bearer {user_b_token}"},
json={"item": "Product X", "quantity": 1}
)
order_id = response.json()["id"] # e.g., 5001
# Now use User A's token to access User B's order — IDOR test
idor_response = requests.get(
f"{BASE_URL}/api/orders/{order_id}",
headers={"Authorization": f"Bearer {user_a_token}"}
)
# This MUST return 403, not 200 with User B's order data
assert idor_response.status_code == 403, \
f"IDOR vulnerability! Got {idor_response.status_code}: {idor_response.text}"
print("BOLA test PASSED — 403 returned for cross-user access")
When testing IDOR, pay special attention to predictable integer IDs. If the API uses sequential integers (/api/orders/5001), an attacker can enumerate all orders trivially. UUIDs are significantly harder to enumerate — but they are not a replacement for server-side authorization checks. Always test both.
Injection in APIs
Injection vulnerabilities in APIs are often more dangerous than in web UIs because they are easily automated and the response may contain large amounts of data.
SQL Injection in Query Parameters
# Test SQL injection in query parameter
import requests
payloads = [
"' OR '1'='1",
"'; DROP TABLE users; --",
"1 UNION SELECT username, password FROM users--",
"1' AND SLEEP(5)--" # Time-based blind SQLi
]
for payload in payloads:
r = requests.get(
f"https://api.example.com/api/products",
params={"category": payload},
headers={"Authorization": f"Bearer {valid_token}"}
)
# Should return 400 Bad Request or sanitized empty result
# Should NEVER return a 200 with unexpected data or SQL error details
assert "SQL" not in r.text.upper(), f"SQL error exposed with payload: {payload}"
assert "syntax error" not in r.text.lower(), f"DB error exposed: {payload}"
NoSQL Injection in JSON Body
# NoSQL injection — MongoDB operator injection
import requests, json
payload = {
"username": {"$gt": ""}, # MongoDB: match any username
"password": {"$gt": ""} # MongoDB: match any password
}
r = requests.post(
"https://api.example.com/api/auth/login",
headers={"Content-Type": "application/json"},
data=json.dumps(payload)
)
# A vulnerable MongoDB app returns 200 with a valid token
# A secure app returns 400 or 401
assert r.status_code != 200, \
f"NoSQL injection succeeded! Status: {r.status_code}"
Input Validation Testing
APIs must validate every field of every request. Failing to do so leads to injection, unexpected behaviour, and denial-of-service via resource exhaustion.
- Field length limits: Submit a string of 100,000 characters in a "name" field — the API must reject it with 400, not process it or crash.
- Type validation: Submit a string where an integer is expected (
"age": "abc") — expect 400 with a validation error. - Unexpected content types: Send
Content-Type: text/xmlon an endpoint that expectsapplication/json— the API must reject it cleanly. - Empty body: Send a POST with no body at all — must return 400, not 500 (a 500 on empty body often indicates unhandled exception).
- Null values: Submit
{"username": null, "password": null}to a login endpoint — must return 400, not authenticate or crash. - Extra fields (mass assignment): Submit
{"username": "newuser", "role": "admin"}to a user creation endpoint — verify thatroleis not accepted and the created user does not have admin privileges.
{}) to a POST endpoint caused an unhandled NullPointerException that returned a full Java stack trace in the 500 response. This revealed the framework version, internal package structure, and environment configuration. The fix was a simple input validation check, but the discovery process — systematically sending malformed requests — is a practice I apply to every API I test.
Rate Limiting Testing
Without rate limiting, APIs are trivially vulnerable to credential stuffing, enumeration, and denial-of-service attacks. Testing rate limiting requires sending rapid, repeated requests and verifying the 429 response.
import requests
import time
BASE_URL = "https://api.example.com"
LOGIN_ENDPOINT = f"{BASE_URL}/api/auth/login"
responses = []
for i in range(30):
r = requests.post(
LOGIN_ENDPOINT,
json={"username": "test@example.com", "password": f"wrong_pass_{i}"}
)
responses.append(r.status_code)
# Verify that rate limiting kicks in — at least some requests should return 429
rate_limited = [s for s in responses if s == 429]
assert len(rate_limited) > 0, \
"No rate limiting detected! All 30 requests succeeded or returned 401 only."
print(f"Rate limiting confirmed: {len(rate_limited)}/30 requests returned 429")
Rate limiting should be tested at multiple levels: per-IP, per-user account, and per-API key. Also test burst behaviour — most APIs allow short bursts before throttling kicks in, which is acceptable — but the limit must eventually be enforced.
Security Headers
Security headers are a low-effort, high-value hardening measure. Check them with curl on every environment:
# Check security headers with curl
curl -I https://api.example.com/api/users/me \
-H "Authorization: Bearer YOUR_TOKEN"
# Expected headers in response:
# X-Content-Type-Options: nosniff
# X-Frame-Options: DENY
# Strict-Transport-Security: max-age=31536000; includeSubDomains
# Content-Security-Policy: default-src 'self'
# Cache-Control: no-store (critical for API responses with sensitive data)
# X-Powered-By: (should be ABSENT — do not reveal framework)
# Server: (should be ABSENT or generic — do not reveal web server version)
HTTPS / TLS Testing
Every API must be served exclusively over HTTPS with a valid certificate and modern cipher suites.
- Send a plain HTTP request:
curl -I http://api.example.com/endpoint— must receive a 301 redirect to HTTPS, never a 200 response. - Verify certificate validity:
curl -v https://api.example.com/endpoint 2>&1 | grep -i "SSL certificate"— must be valid and not expired. - Test for weak cipher suites: use
nmap --script ssl-enum-ciphers -p 443 api.example.com— TLSv1.0 and SSLv3 must be absent. - Verify the certificate is issued by a trusted CA and the hostname matches — reject self-signed certs in staging and production.
OWASP ZAP for API Testing
OWASP ZAP (Zed Attack Proxy) is a free, open-source security testing tool widely used by QA engineers. Its API scan mode is particularly well-suited for REST APIs.
# Run OWASP ZAP API scan from command line (Docker)
docker run -v $(pwd):/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
-t https://api.example.com/openapi.json \
-f openapi \
-r zap_report.html \
-x zap_report.xml
# Parameters:
# -t : target API definition URL (OpenAPI/Swagger JSON)
# -f : format of the API definition (openapi or graphql)
# -r : HTML report output file
# -x : XML report (for CI parsing)
# For authenticated APIs, pass auth header:
docker run -v $(pwd):/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
-t https://api.example.com/openapi.json \
-f openapi \
-r zap_report.html \
-H "Authorization: Bearer YOUR_TOKEN"
ZAP performs four types of scanning: Spider (discovers endpoints), Passive Scan (analyses traffic without sending attacks), Active Scan (sends payloads to detect vulnerabilities), and Ajax Spider (for JavaScript-heavy APIs). For CI integration, fail the build if ZAP reports any High-severity findings.
Python Security Test Suite
A structured pytest security test class that covers the most common API security issues:
import pytest
import requests
BASE_URL = "https://api.example.com"
class TestAPISecuritySuite:
@pytest.fixture(autouse=True)
def setup(self):
# Get valid tokens for two different users
self.user_a_token = self._get_token("user_a@test.com", "Password123!")
self.user_b_token = self._get_token("user_b@test.com", "Password456!")
self.resource_id = self._create_resource(self.user_b_token)
def _get_token(self, email, password):
r = requests.post(f"{BASE_URL}/api/auth/login",
json={"email": email, "password": password})
return r.json()["access_token"]
def _create_resource(self, token):
r = requests.post(f"{BASE_URL}/api/items",
headers={"Authorization": f"Bearer {token}"},
json={"name": "Test Item"})
return r.json()["id"]
def test_auth_bypass_no_token(self):
"""Endpoint must reject requests with no auth token."""
r = requests.get(f"{BASE_URL}/api/items")
assert r.status_code == 401
def test_auth_bypass_invalid_token(self):
"""Endpoint must reject malformed tokens."""
r = requests.get(f"{BASE_URL}/api/items",
headers={"Authorization": "Bearer NOTAVALIDTOKEN"})
assert r.status_code == 401
def test_idor_cross_user_access(self):
"""User A must not access User B's resource — BOLA/IDOR test."""
r = requests.get(
f"{BASE_URL}/api/items/{self.resource_id}",
headers={"Authorization": f"Bearer {self.user_a_token}"}
)
assert r.status_code == 403, \
f"IDOR vulnerability: {r.status_code} — {r.text[:100]}"
def test_sql_injection_query_param(self):
"""SQL injection in query parameters must be rejected."""
r = requests.get(
f"{BASE_URL}/api/items",
params={"search": "' OR '1'='1"},
headers={"Authorization": f"Bearer {self.user_a_token}"}
)
assert "SQL" not in r.text.upper()
assert "syntax" not in r.text.lower()
def test_rate_limiting(self):
"""Rate limiting must activate within 20 rapid requests."""
statuses = []
for _ in range(20):
r = requests.post(f"{BASE_URL}/api/auth/login",
json={"email": "nonexistent@test.com",
"password": "wrong"})
statuses.append(r.status_code)
assert 429 in statuses, "Rate limiting not enforced"
def test_security_headers_present(self):
"""Required security headers must be present."""
r = requests.get(f"{BASE_URL}/api/items",
headers={"Authorization": f"Bearer {self.user_a_token}"})
assert "X-Content-Type-Options" in r.headers
assert r.headers.get("X-Content-Type-Options") == "nosniff"
assert "Strict-Transport-Security" in r.headers
Tools Comparison: API Security Testing
| Tool | Coverage | Automation | Learning Curve | Cost |
|---|---|---|---|---|
| Manual (curl / Postman) | Targeted — only what you test manually | None | Low | Free |
| OWASP ZAP | Broad OWASP Top 10 coverage via active scan | High — CLI, Docker, CI integration | Medium | Free (open-source) |
| Burp Suite Pro | Deep — scanner + manual Repeater + Intruder | Medium — scanner automatable, Repeater manual | High | $449/year per user |
| Postman + Newman | Limited to security tests you write explicitly | High — Newman CI runner | Low | Free (Community) / paid plans |
Security Test Checklist — 20 Checks for Every API
| # | Check | Category |
|---|---|---|
| 1 | No auth header → 401 | Auth |
| 2 | Invalid token → 401 | Auth |
| 3 | Expired token → 401 | Auth |
| 4 | JWT alg=none rejected | Auth |
| 5 | User A cannot access User B's resource → 403 | Authz / BOLA |
| 6 | Standard user cannot call admin endpoints → 403 | Authz / BFLA |
| 7 | SQL injection in query params rejected | Injection |
| 8 | NoSQL injection in JSON body rejected | Injection |
| 9 | XSS payload in fields escaped in response | Injection |
| 10 | Empty body returns 400, not 500 | Input Validation |
| 11 | Oversized payload rejected with 413 or 400 | Input Validation |
| 12 | Mass assignment fields not accepted | Input Validation |
| 13 | Rate limiting enforced — 429 after N requests | Rate Limiting |
| 14 | X-Content-Type-Options: nosniff header present | Headers |
| 15 | Strict-Transport-Security header present | Headers |
| 16 | No sensitive data in response headers or error messages | Info Disclosure |
| 17 | HTTP redirects to HTTPS (no plain HTTP serving) | TLS |
| 18 | Internal URL in URL params rejected (SSRF) | SSRF |
| 19 | Old API version (/v0/, /v1/) disabled or identical security | Inventory |
| 20 | Stack trace not exposed in 500 error responses | Misconfiguration |
Best Practices for API Security Testing
- Test negative auth paths on every endpoint: Do not assume that because your happy-path tests pass, auth is working. Every single endpoint needs an explicit test without credentials and with someone else's credentials.
- Log all 5xx responses in a security context: A 500 error is often a sign of an unhandled injection payload or malformed input that the server was not prepared for. Always investigate 500s in security testing.
- Include security tests in the CI pipeline: Security tests that only run before major releases catch bugs far too late. Add them to the nightly regression run at minimum, or to every PR merge.
- Test across API versions: Many teams ship a new v2 API but leave v1 running indefinitely. Old API versions often lack the same security improvements applied to the new version. Test both.
- Document your security test cases in the same way as functional tests: Give each security test a clear name, expected result, and failure description — the same discipline that makes functional tests maintainable applies equally here.
Back to Blog