ekofyi
Security Research9 min read

Common auth mistakes I find when reverse-engineering APIs

After years of poking at APIs that weren't meant to be poked at, these are the auth patterns that break most often — and why.

I've reverse-engineered enough APIs to notice patterns. Not in how auth is supposed to work — in how it actually breaks. These are the mistakes I see over and over, with real examples of what they look like in the wild.

1. Token in the URL query string

http
GET /api/v1/documents?token=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo0Mn0.abc HTTP/1.1
Host: app.target.com

This token is now in: server access logs, browser history, any proxy logs, the Refererheader if the user clicks an external link, and whatever analytics platform captures full URLs. I've extracted valid tokens from Google-cached URLs, Wayback Machine snapshots, and shared Slack screenshots where someone forgot to redact the URL bar.

💡 The fix

Always send tokens in the Authorization header. If you need URL-based auth (webhooks, email links), use short-lived, single-use tokens with a separate validation endpoint.

2. Tokens that never expire

I decode every JWT I intercept. Here's what I often find:

json
{
  "sub": "user_42",
  "email": "admin@company.com",
  "role": "admin",
  "iat": 1704067200,
  "exp": 1735689600   // ← 1 year from issuance
}

A year-long token. No refresh rotation. If I grab this once — from a log, a leaked .env, a compromised laptop — I have admin access for 365 days. I've seen worse: tokens with no expclaim at all, meaning they're valid forever.

⚠️ What I test

I take an old token (even months old) and replay it. If it still works, there's no token rotation and no revocation mechanism. This is surprisingly common in internal APIs.

3. Client-side role enforcement

The frontend hides the admin panel. The API doesn't check. I see this pattern constantly in SPAs:

javascript
// Frontend router (React/Vue/Angular)
if (user.role === 'admin') {
  showAdminPanel();
}

// But the API endpoint has NO role check:
// GET /api/admin/users HTTP/1.1
// Authorization: Bearer <regular_user_token>
//
// HTTP/1.1 200 OK
// [{"id": 1, "email": "ceo@company.com", "role": "admin"}, ...]

The developer assumed the router IS the security boundary. It's not. I just call the endpoint directly with curl. This is the #1 reason I always map the full API surface before testing auth — hidden endpoints are often unprotected endpoints.

4. Predictable reset tokens

I request a password reset and intercept the token. Then I look at the pattern:

bash
# Request 1 at 14:30:01 → token: 173820601142
# Request 2 at 14:30:05 → token: 173820601542
# Request 3 at 14:30:09 → token: 173820601942

# Pattern: timestamp (unix ms) + user_id
# Predictable. Brute-forceable in seconds.

Even "random" 6-digit codes are vulnerable if there's no rate limiting on the verification endpoint:

bash
$ for code in $(seq 100000 999999); do
  resp=$(curl -so /dev/null -w "%{http_code}" \
    -X POST https://api.target.com/reset/verify \
    -d '{"code":"'$code'","email":"victim@company.com"}')
  [ "$resp" != "400" ] && echo "FOUND: $code" && break
done

# 900,000 attempts. No lockout. No rate limit.
# Average time to crack: ~15 minutes.

💡 Defense

Use cryptographically random tokens (32+ bytes). Rate limit verification attempts (max 5 per email per hour). Lock the account after 10 failed attempts. Expire tokens after 15 minutes.

5. IDOR on user-scoped endpoints

The single most common vulnerability I find. The test is trivial:

http
# Authenticated as user 42
GET /api/users/42/documents HTTP/1.1  → 200 ✓ (my docs)

# Change the ID
GET /api/users/43/documents HTTP/1.1  → 200 ✓ (someone else's docs)

# The API checks: "is this request authenticated?" ✓
# The API does NOT check: "does this user own resource 43?" ✗

I test every endpoint that has an ID parameter. User IDs, document IDs, org IDs, invoice IDs — anything that references a specific resource. In my experience, about 30% of APIs with sequential IDs have at least one IDOR.

6. JWT audience confusion

In microservice architectures, multiple services share the same JWT issuer. The token has an aud (audience) claim — but does each service actually validate it?

json
// Token issued for: billing-service
{
  "sub": "user_42",
  "aud": "billing-service",
  "role": "user",
  "iss": "auth.internal.company.io"
}

// Send it to: admin-service
// GET /api/admin/config HTTP/1.1
// Authorization: Bearer <billing-service-token>
//
// HTTP/1.1 200 OK   ← admin-service accepted a billing token
// {"database_url": "postgres://...", "api_keys": [...]}

The admin service validated the signature (correct) but didn't check the audience (wrong). A token meant for one service grants access to another. This is lateral movement within the architecture.

Why these keep happening

text
┌─ THE AUTH DECAY TIMELINE ────────────────────────┐
│                                                  │
│  Month 1:  Auth implemented. Works. Tested.      │
│  Month 3:  New endpoints added. Auth copied.     │
│  Month 6:  Intern adds endpoint. Forgets auth.   │
│  Month 12: Refactor. Auth middleware skipped.    │
│  Month 18: "Why does /api/internal/* work        │
│             without a token?"                    │
│                                                  │
└──────────────────────────────────────────────────┘

Auth is implemented once, early in the project, and never systematically re-audited. The codebase grows, new endpoints get added by different developers, and nobody re-checks whether the auth middleware actually covers everything. That's the gap I exploit — and the gap I help teams close.

Related posts

Written by Eko

If you found this useful, follow @ekofyi on X for more notes like this — or get in touch if you have a problem to solve.