About me
Blog
Europe/Berlin
--:--:--

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.
A SaaS product, FastAPI backend, SQLAlchemy as ORM. The endpoint for profile updates looked like this:
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"}
Looks harmless, right? The user sends a JSON with their new display name, maybe a new email address. The code iterates over the fields and sets them directly on the ORM object. The problem: The User model had a role field. And an is_active field. And an email_verified field. A single cURL command:
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}'
Done. My account was admin. I had access to the admin panel, could manage other users, view billing data — the whole program. The nasty part: The endpoint was behind an authentication middleware. The devs thought, "only authenticated users can reach this, so it's safe." But authentication is not authorization — and certainly not input validation. The solution is an explicit schema that precisely defines which fields the user is allowed to change:
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"}
The difference: Pydantic validates the input and discards anything not defined in the schema. Even if someone sends "role": "admin" — the field doesn't exist in the DTO and is ignored. Rule of thumb: Never map request.json() directly onto an ORM model. Always put an explicit input schema in between. This applies equally to Flask with Marshmallow, Express with Zod or Joi.
An accounting app. The endpoint for retrieving invoices:
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
Authentication? Check. The user must be logged in. But look closely: where is it verified that the invoice belongs to this user? Nowhere. I was logged in as a test user and had my own invoice at /api/invoices/847. Then I simply incremented the ID:
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
Result: 312 out of 1000 invoices were accessible. With complete customer data, amounts, bank details. A GDPR nightmare. This is IDOR — Insecure Direct Object Reference. One of the most common findings in the OWASP Top 10, and yet I see it in almost every project. Two things: First, an ownership check directly in the query. Second, UUIDs instead of sequential IDs to make enumeration harder.
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
Important: The 404 instead of 403 is intentional. If you return a 403 when permission is missing, you reveal to the attacker that the resource exists. A 404 keeps them in the dark. For more complex scenarios — such as when multiple users should have access to an invoice — a centralized authorization middleware is worthwhile:
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

"We use SQLAlchemy, SQL injection isn't an issue." I hear that a lot. And usually it's true — until someone builds a raw query in one spot because the ORM syntax was too cumbersome. In one project, I found a search function:
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
An f-string in a SQL statement. Right in the middle of ORM code. The other 200 endpoints used clean SQLAlchemy queries — but this one was handwritten. Classic SQL injection. First, confirmation that the input passes through unfiltered:
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. Parameterized queries. Always. No exceptions.
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
Or even better — back to the ORM:
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
Bonus: We also set up a pre-commit hook that searches for f-strings near db.execute:
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
Is it perfect? No. But it catches the most obvious cases and makes devs aware of the problem.
A login endpoint. No rate limiting. No account lockout. No CAPTCHA. Nothing.
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 });
});
Functionally correct. Security-wise, an open invitation. Credential stuffing. I took a publicly available leak database (yes, they exist, and yes, attackers use them) and checked the email addresses against the login endpoint.
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)
With asyncio and 50 parallel requests, I could test about 2,000 logins per minute. Within 10 minutes, I had 23 valid accounts. Same story with the /api/auth/reset-password endpoint: Without rate limiting, I could test which email addresses are registered (different error messages for existing vs. non-existing accounts — another classic mistake). Rate limiting on multiple levels:
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);
Plus an account lockout mechanism:
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 });
});
Note: The error message is identical for a locked account and a wrong password. Otherwise, an attacker can determine when an account exists and when it's locked.
A FastAPI project in production. Debug mode was off — but the global exception handler was missing. The result for an invalid request:
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"
}
In a single error message, I got:
  • 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)
I didn't even need to actually attack. I simply sent systematically malformed requests and collected the error messages:
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")
Within an hour, I had a complete picture of the internal architecture: database schema, internal IP ranges, filesystem structure, libraries used with version numbers. All just from error messages. A global exception handler that shows generic errors externally and logs everything cleanly internally:
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 trick with the error_id: The user sees a UUID they can give to support. Internally, you can use it to find the full traceback in your log system (ELK, Grafana Loki, whatever). Maximum transparency internally, minimal information disclosure externally.
If you look at these five findings, a pattern emerges: None of these mistakes are particularly exotic. They're not zero-days, not side-channel attacks, not cryptographic weaknesses. They're basics.
  • 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
The problem isn't a lack of knowledge. Most devs know that SQL injection exists. The problem is everyday reality: deadlines, feature pressure, "we'll do it later." Security bugs don't happen because someone doesn't know better, but because nobody is looking.
Before you switch to the next tab, here are three things you can do today:
  1. 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.
  2. 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.
  3. 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.
And if you want someone to do this systematically for you — with an audit report, prioritized findings, and concrete fixes — reach out. That's exactly my job. Get in touch