The Equifax Breach: 147 Million Records from One Unpatched Vulnerability
The OWASP Top 10 for APIs helps developers identify and prevent common API security risks before they can be exploited by attackers.In 2017, Equifax suffered the most devastating data breach in history. Attackers exploited a known Apache Struts vulnerability (CVE-2017-5638) that had a patch available for TWO MONTHS before the breach. Through this single entry point, they accessed names, Social Security numbers, birth dates, and addresses of 147 million Americans. Equifax eventually paid over $700 million in settlements. The breach hit OWASP's top categories: vulnerable components (unpatched library), broken access control (lateral movement after entry), and insufficient logging (the breach went undetected for 76 days).
The OWASP API Security Top 10
OWASP API Security Top 10 (2023):
Rank Vulnerability Real-World Example Prevalence
---- --------------------------- ------------------------- ----------
1 Broken Object-Level AuthZ IDOR on user profiles Very High
2 Broken Authentication No rate limit on login High
3 Broken Object Property-Level API returns password hash Very High
4 Unrestricted Resource Usage No pagination limits High
5 Broken Function-Level AuthZ User calls admin endpoints High
6 Server-Side Request Forgery SSRF via URL parameter Medium
7 Security Misconfiguration CORS *, debug mode on Very High
8 Lack of Protection from Bots Credential stuffing High
9 Improper Asset Management Old API v1 still running High
10 Unsafe API Consumption Trusting third-party APIs Medium1. Broken Object-Level Authorization (BOLA/IDOR)
The #1 API Vulnerability:
VULNERABLE:
GET /api/users/42/orders <- Alice (user 42) sees her orders
GET /api/users/43/orders <- Alice changes 42 to 43
Sees Bob's orders! No check!
The code:
@app.get('/users/{user_id}/orders')
def get_orders(user_id: int):
return db.query(Order).filter(Order.user_id == user_id).all()
# BUG: never checks if requesting user == user_id
FIXED:
@app.get('/users/{user_id}/orders')
def get_orders(user_id: int, current_user = Depends(get_current_user)):
if current_user.id != user_id and not current_user.is_admin:
raise HTTPException(403, 'Access denied')
return db.query(Order).filter(Order.user_id == user_id).all()
BETTER (eliminate the vector entirely):
@app.get('/me/orders') <- No user_id in URL at all!
def get_my_orders(current_user = Depends(get_current_user)):
return db.query(Order).filter(Order.user_id == current_user.id).all()3. Broken Object Property-Level Access
Excessive Data Exposure:
VULNERABLE - API returns entire user object:
GET /api/users/42
{
"id": 42,
"name": "Alice",
"email": "alice@example.com",
"password_hash": "$2b$12$LJ3m4...", <- EXPOSED!
"ssn": "123-45-6789", <- EXPOSED!
"internal_notes": "VIP customer", <- EXPOSED!
"created_at": "2024-01-15"
}
Frontend just shows name and email, but attacker reads raw response.
FIXED - Explicit response schema:
class UserResponse(BaseModel):
id: int
name: str
email: str
created_at: datetime
# Only these fields. Nothing else leaves the server.
Mass Assignment (the flip side):
VULNERABLE:
PUT /api/users/42 { "name": "Alice", "role": "admin" }
user.update(**request.json()) <- User just made themselves admin!
FIXED:
class UserUpdate(BaseModel):
name: str
email: str
# role is NOT in this schema - cannot be set by users4. Injection Attacks
SQL Injection (still the most exploited vulnerability):
VULNERABLE:
query = f"SELECT * FROM users WHERE name = '{user_input}'"
Attacker input: ' OR '1'='1'; DROP TABLE users; --
Resulting query: SELECT * FROM users WHERE name = '' OR '1'='1'; DROP TABLE users; --'
FIXED (parameterized queries):
cursor.execute('SELECT * FROM users WHERE name = %s', (user_input,))
# Database treats user_input as DATA, never as SQL code.
# Even if input is "DROP TABLE", it searches for that literal string.
NoSQL Injection:
VULNERABLE:
db.users.find({ "username": req.body.username, "password": req.body.password })
Attacker sends: { "password": { "$ne": "" } } <- Matches any non-empty password!
FIXED:
Validate types before querying. password must be a string, not an object.
Command Injection:
VULNERABLE:
os.system(f'convert {user_filename} output.png') <- user_filename = '; rm -rf /'
FIXED:
subprocess.run(['convert', user_filename, 'output.png']) <- List form, no shellSecurity Hardening Checklist
BOLA: always verify resource ownership, prefer /me/ endpoints over /users/{id}/ for self-access.
Authentication: rate limit login to 5 attempts/minute, implement account lockout, use bcrypt with cost 12+.
Data exposure: use explicit response schemas (whitelist fields), never return raw database objects.
Injection: parameterized queries everywhere, validate input types, use ORM methods (not raw SQL).
Mass assignment: explicit update schemas, never apply raw request body to database models.
SSRF: validate and whitelist URLs, block internal IP ranges (169.254.x.x, 10.x.x.x, 127.0.0.1).
CORS: never use Access-Control-Allow-Origin: * on authenticated endpoints. Whitelist specific origins.
Dependencies: automated vulnerability scanning (Dependabot, Snyk), update within 48 hours of critical CVEs.
Logging: log all auth failures, privilege escalation attempts, and unusual patterns. Alert on anomalies.
Interview Tip
When security comes up in system design interviews, mention OWASP proactively - it shows you think about security from the start. Lead with BOLA (the #1 API vulnerability) and explain the /me/ endpoint pattern that eliminates IDOR vectors entirely. For any database interaction, mention parameterized queries. When designing APIs, show explicit request/response schemas that whitelist allowed fields (prevents both data exposure and mass assignment). The advanced move: mention defense in depth - even if application code has a bug, database-level protections (RLS, parameterized queries) prevent exploitation.
Key Takeaway
BOLA (broken authorization) is the #1 API vulnerability - always verify resource ownership, not just authentication. Never trust client input, never expose unnecessary data, always use parameterized queries, and rate limit everything. Security is not a feature to add later - it is a requirement from day one.
