Back to All Articles
Security

API Security Testing — Complete QA Guide

Honnesh Muppala May 5, 2026 14 min read

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

Attacker / Test Client
API Endpoint (HTTPS)
Auth Layer
(JWT / OAuth)
Rate Limiter
(429 on breach)
Input Validation
(schema / type)
Business Logic Layer
Database / Data Store

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.

From Amazon: While testing an internal fulfillment API at Amazon, I discovered that sending an empty JSON body ({}) 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.

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
From Virtusa: I built a security test class similar to the one above as part of our API regression suite at Virtusa. Rather than running it only before major releases, we added it to the nightly build. This caught a BOLA regression — a code refactor had accidentally removed the ownership check from a new endpoint — before it was ever deployed to staging. Integrating security tests into the normal CI pipeline, rather than treating them as a separate "security phase," is what makes them effective.

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
1No auth header → 401Auth
2Invalid token → 401Auth
3Expired token → 401Auth
4JWT alg=none rejectedAuth
5User A cannot access User B's resource → 403Authz / BOLA
6Standard user cannot call admin endpoints → 403Authz / BFLA
7SQL injection in query params rejectedInjection
8NoSQL injection in JSON body rejectedInjection
9XSS payload in fields escaped in responseInjection
10Empty body returns 400, not 500Input Validation
11Oversized payload rejected with 413 or 400Input Validation
12Mass assignment fields not acceptedInput Validation
13Rate limiting enforced — 429 after N requestsRate Limiting
14X-Content-Type-Options: nosniff header presentHeaders
15Strict-Transport-Security header presentHeaders
16No sensitive data in response headers or error messagesInfo Disclosure
17HTTP redirects to HTTPS (no plain HTTP serving)TLS
18Internal URL in URL params rejected (SSRF)SSRF
19Old API version (/v0/, /v1/) disabled or identical securityInventory
20Stack trace not exposed in 500 error responsesMisconfiguration

Best Practices for API Security Testing


Back to Blog
From Experience — Viasat: At Viasat, every IFC software load goes through PAT (Product Acceptance Testing) before customer delivery, which includes validating security configurations against a Golden Build baseline. When the baseline changes — a root password hash rotation, TLS certificate update, or secrets rotation — the PAT process ensures no production unit ships with a stale or misconfigured security posture. Treating security configuration as a testable, version-controlled artefact rather than a one-time setup is a discipline that prevents entire classes of vulnerability from reaching airline customers.