← All Articles
Last updated: 2026-03-30

How to Do a Security-Focused Code Review: Checklist for Python and TypeScript

Security code review checklist. SQL injection, XSS, secrets, input validation — for Python and TypeScript projects.

TL;DR

A code review that only checks "does it work?" misses the most expensive bugs: security vulnerabilities. This guide gives you a systematic checklist for security-focused code reviews in Python and TypeScript projects. It covers injection attacks, authentication flaws, secrets exposure, and language-specific traps — plus the automated tools that should be running before any human reviews anything.

Prerequisites

Step 1: Universal Security Checklist

These apply to every codebase regardless of language.

Input Validation

[ ] All user input is validated on the server side (client-side is UX, not security)
[ ] Input length limits are enforced
[ ] File uploads: type, size, and name are validated server-side
[ ] API request bodies are validated against a schema (Pydantic, Zod, etc.)
[ ] URL parameters, headers, and cookies are treated as untrusted input
[ ] No raw user input is passed to:
    - SQL queries (use parameterized queries)
    - Shell commands (use subprocess with list args, not shell=True)
    - File paths (use allowlists, not user-controlled paths)
    - Regular expressions (ReDoS risk)
    - Template engines (SSTI risk)
    - eval() / exec() / Function() constructors

Output Encoding

[ ] HTML output is auto-escaped by the template engine
[ ] JSON responses set Content-Type: application/json
[ ] User-supplied data in URLs is properly encoded
[ ] CSV/Excel exports escape formula injection (=, +, -, @, \t, \r)
[ ] Error messages don't leak stack traces, file paths, or database details

Data Exposure

[ ] API responses don't include more data than the client needs
[ ] Password fields are never included in API responses
[ ] Logging does not include passwords, tokens, PII, or credit card numbers
[ ] Debug mode is disabled in production configuration
[ ] Source maps are not deployed to production (or are gated)

Step 2: Python-Specific Security Review

SQL Injection

# VULNERABLE — string formatting in SQL
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
cursor.execute("SELECT * FROM users WHERE email = '%s'" % email)
cursor.execute("SELECT * FROM users WHERE email = '" + email + "'")

# SAFE — parameterized query
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))

# SAFE — ORM (SQLAlchemy)
User.query.filter_by(email=email).first()

# WATCH OUT — raw() in Django ORM
# Even with an ORM, raw SQL can be injected
User.objects.raw(f"SELECT * FROM users WHERE email = '{email}'")  # VULNERABLE
User.objects.raw("SELECT * FROM users WHERE email = %s", [email])  # SAFE

Dangerous Functions

# NEVER use with untrusted input:
eval(user_input)           # Arbitrary code execution
exec(user_input)           # Arbitrary code execution
os.system(user_input)      # Shell injection
os.popen(user_input)       # Shell injection
subprocess.call(cmd, shell=True)  # Shell injection if cmd contains user input
__import__(user_input)     # Arbitrary module loading
compile(user_input, ...)   # Code compilation

# SAFE alternative for subprocess:
subprocess.run(["ls", "-la", directory], shell=False, check=True)

# NEVER deserialize untrusted data with:
import pickle
pickle.loads(untrusted_data)  # Arbitrary code execution

import yaml
yaml.load(untrusted_data)     # Arbitrary code execution (use yaml.safe_load)

import marshal
marshal.loads(untrusted_data) # Arbitrary code execution

Path Traversal

# VULNERABLE — user controls file path
def download(filename):
    return send_file(f"/uploads/{filename}")  # ../../../etc/passwd

# SAFE — validate and normalize path
import os
def download(filename):
    # Remove directory traversal
    safe_name = os.path.basename(filename)
    full_path = os.path.join("/uploads", safe_name)
    # Verify the resolved path is within the allowed directory
    if not os.path.realpath(full_path).startswith("/uploads"):
        abort(403)
    return send_file(full_path)

SSRF (Server-Side Request Forgery)

# VULNERABLE — user controls URL
import requests
def fetch_url(url):
    return requests.get(url).text  # Can access internal services, cloud metadata

# SAFE — validate URL scheme and host
from urllib.parse import urlparse

ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"}

def fetch_url(url):
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https"):
        raise ValueError("Invalid scheme")
    if parsed.hostname not in ALLOWED_HOSTS:
        raise ValueError("Host not allowed")
    # Also block private IP ranges: 10.x, 172.16-31.x, 192.168.x, 169.254.x
    return requests.get(url, timeout=5).text

Step 3: TypeScript-Specific Security Review

XSS (Cross-Site Scripting)

// VULNERABLE — innerHTML with user data
document.getElementById("output").innerHTML = userInput;

// VULNERABLE — React dangerouslySetInnerHTML
<div dangerouslySetInnerHTML={{ __html: userInput }} />

// SAFE — textContent (no HTML parsing)
document.getElementById("output").textContent = userInput;

// SAFE — React auto-escapes by default
<div>{userInput}</div>  // This is safe

// WATCH OUT — URLs in href attributes
// VULNERABLE: javascript: protocol
<a href={userProvidedUrl}>Click</a>  // javascript:alert(1)

// SAFE — validate URL protocol
const isValidUrl = (url: string): boolean => {
  try {
    const parsed = new URL(url);
    return ["http:", "https:"].includes(parsed.protocol);
  } catch {
    return false;
  }
};

Prototype Pollution

// VULNERABLE — deep merge without protection
function deepMerge(target: any, source: any) {
  for (const key in source) {
    if (typeof source[key] === "object") {
      target[key] = deepMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}
// Attack: deepMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'))
// Now EVERY object has isAdmin === true

// SAFE — check for dangerous keys
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function safeMerge(target: Record<string, unknown>, source: Record<string, unknown>) {
  for (const key of Object.keys(source)) {
    if (FORBIDDEN_KEYS.has(key)) continue;
    if (typeof source[key] === "object" && source[key] !== null && !Array.isArray(source[key])) {
      target[key] = safeMerge((target[key] as Record<string, unknown>) || {}, source[key] as Record<string, unknown>);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

// BETTER — use Object.create(null) for lookup objects
const config = Object.create(null);  // No prototype chain

SQL Injection in TypeScript ORMs

// VULNERABLE — Prisma raw queries
await prisma.$queryRaw(`SELECT * FROM users WHERE email = '${email}'`);

// SAFE — Prisma tagged template (auto-parameterized)
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;

// VULNERABLE — TypeORM
await repo.query(`SELECT * FROM users WHERE email = '${email}'`);

// SAFE — TypeORM parameters
await repo.query("SELECT * FROM users WHERE email = $1", [email]);

// SAFE — TypeORM query builder
await repo.createQueryBuilder("user")
  .where("user.email = :email", { email })
  .getOne();

Insecure Dependencies

// Check for known vulnerabilities
npm audit

// Check for outdated packages
npm outdated

// IMPORTANT: Review postinstall scripts in package.json
// Malicious packages can execute code during npm install
// Check: does this package need a postinstall script?
// Tool: use 'npm install --ignore-scripts' for untrusted packages

Step 4: Authentication and Authorization Review

Authentication Checklist

[ ] Passwords are hashed with bcrypt, scrypt, or argon2 (never MD5/SHA)
[ ] Password reset tokens are single-use and time-limited (max 1 hour)
[ ] Session tokens are cryptographically random (min 128 bits)
[ ] Cookies have: HttpOnly, Secure, SameSite=Strict/Lax flags
[ ] JWT tokens: short expiry, signature verified, algorithm pinned (no "none")
[ ] Rate limiting on login endpoints (prevent brute force)
[ ] Account lockout after N failed attempts (with notification)
[ ] MFA is available for sensitive operations

Authorization Checklist

[ ] Every API endpoint checks authorization (not just authentication)
[ ] IDOR protection: users can only access their own resources
    (user.id === resource.ownerId, checked server-side)
[ ] Role checks are centralized, not scattered across handlers
[ ] Admin endpoints are in a separate route group with middleware
[ ] File access checks ownership, not just authentication
[ ] Changing user role/permissions requires re-authentication

Common IDOR Pattern

# VULNERABLE — no ownership check
@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int):
    return db.query(Invoice).get(invoice_id)  # Any user can see any invoice

# SAFE — ownership check
@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int, current_user: User = Depends(get_current_user)):
    invoice = db.query(Invoice).filter(
        Invoice.id == invoice_id,
        Invoice.user_id == current_user.id  # Ownership check
    ).first()
    if not invoice:
        raise HTTPException(404)
    return invoice

Step 5: Secrets and Configuration Review

Secrets Scan

# Search for hardcoded secrets in the codebase
# Run these and review every match:

# API keys
grep -rn "api_key\|apikey\|api-key" --include="*.py" --include="*.ts" .

# Passwords
grep -rn "password\s*=\|passwd\|secret" --include="*.py" --include="*.ts" .

# AWS keys
grep -rn "AKIA[0-9A-Z]\{16\}" .

# Private keys
grep -rn "BEGIN.*PRIVATE KEY" .

# JWTs (they look like: eyJ...)
grep -rn "eyJ[a-zA-Z0-9]" --include="*.py" --include="*.ts" .

# Connection strings
grep -rn "postgresql://\|mysql://\|mongodb://\|redis://" .

Configuration Review

[ ] .env files are in .gitignore
[ ] No .env files in the repository history (check: git log --all --full-history -- .env)
[ ] Different configs for dev/staging/production
[ ] Production configs have:
    - DEBUG=false
    - Strict CORS origins (not wildcard *)
    - HTTPS-only cookies
    - Rate limiting enabled
    - Logging configured (without PII)
[ ] Environment variables for all secrets (never in code/config files)
[ ] Default passwords have been changed

Step 6: Automated Security Tools

Python Toolchain

# Bandit — finds common security issues in Python
pip install bandit
bandit -r src/ -f json -o bandit-report.json

# Safety — checks for known vulnerable dependencies
pip install safety
safety check --json

# Semgrep — pattern-based code analysis
pip install semgrep
semgrep --config=p/python --config=p/security-audit src/

# Pre-commit hook (.pre-commit-config.yaml)
repos:
  - repo: https://github.com/PyCQA/bandit
    rev: '1.8.3'
    hooks:
      - id: bandit
        args: ['-c', 'bandit.yaml']

TypeScript Toolchain

# ESLint with security plugins
npm install --save-dev eslint-plugin-security eslint-plugin-no-unsanitized

// .eslintrc.json
{
  "plugins": ["security", "no-unsanitized"],
  "extends": ["plugin:security/recommended"],
  "rules": {
    "security/detect-object-injection": "warn",
    "security/detect-non-literal-require": "error",
    "security/detect-eval-with-expression": "error",
    "no-unsanitized/method": "error",
    "no-unsanitized/property": "error"
  }
}

# npm audit — checks dependencies
npm audit --production

# Snyk — more comprehensive dependency analysis
npx snyk test
npx snyk code test  # SAST (static analysis)

CI/CD Integration

# GitHub Actions example
name: Security Scan
on: [pull_request]
jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Bandit (Python)
        run: pip install bandit && bandit -r src/ --severity-level medium
      - name: Run npm audit (TypeScript)
        run: npm audit --audit-level=high
      - name: Run Snyk
        uses: snyk/actions@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
      - name: Run Semgrep
        uses: returntocorp/semgrep-action@v1
        with:
          config: p/security-audit

Troubleshooting & Considerations

"The team sees security reviews as blocking"

Automate the obvious checks. Bandit, ESLint security rules, and npm audit should run in CI before a human ever looks at the PR. The human review then focuses on logic flaws, authorization, and business-logic security — things tools can't catch. This reduces review friction significantly.

"Too many false positives from automated tools"

Tune the tools. Bandit supports per-file and per-line suppression (# nosec) with justification. ESLint rules can be configured per-directory. Create a baseline file of known issues and focus on new findings only. But: never globally disable a security rule because it's "noisy."

"We're using a framework — aren't we safe?"

Frameworks protect you from common mistakes when used correctly. Django auto-escapes templates (but |safe filter bypasses it). React auto-escapes JSX (but dangerouslySetInnerHTML bypasses it). ORMs prevent SQL injection (but raw() queries bypass it). The framework is a safety net with holes. Review the holes.

"We don't have time for security reviews"

You don't have time for a data breach either. A security-focused review adds 15-30 minutes per PR. A data breach costs months and potentially the business. Start with the automated toolchain — it takes 30 minutes to set up and runs automatically forever.

Prevention & Best Practices

Shift Left

Security tools should run on every developer's machine (pre-commit hooks) and in CI. Don't wait for a security review at the end. By then, the code is "done" and nobody wants to rewrite it.

Security Champions

Designate one developer per team as the "security champion." They attend security training, stay current on vulnerabilities, and review the security aspects of PRs. Rotate the role quarterly to spread knowledge.

Dependency Hygiene

Run npm audit and safety check weekly, not just in CI. Subscribe to security advisories for your major dependencies. Use Dependabot or Renovate for automated dependency updates. Pin exact versions in production (package-lock.json, requirements.txt with hashes).

Threat Modeling

Before a security review, know what you're protecting. Spend 30 minutes with the team asking: What data is sensitive? What are the attack surfaces? What happens if this is compromised? The answers inform where to focus review effort.

Review Checklist as Code

Put the security checklist in the PR template. Every PR should have a section where the author confirms they've checked for the relevant security items. This normalizes security thinking as part of the development process, not as an afterthought.

Need Expert Help?

Want a professional code review? €150, 2-hour deep dive + written report.

Book Now — €150

100% money-back guarantee

HR

Harald Roessler

Infrastructure Engineer with 20+ years experience. Founder of DSNCON GmbH.