Skip to main content
Python · lesson 28 of 34
Python intermediate Lesson 28 of 34

Also part of: Cryptography Web Security (OWASP Top 10, fix-side)

Python Security

Run real attacks against local Python code and fix them: SQL injection, shell injection, path traversal, weak tokens and password hashes.

By SkillByExample · Updated

Security bugs are easiest to understand by watching them happen. Each section below attacks a small local program — an in-memory database, a temp directory, echo — and then fixes it. Nothing touches the network. Output is from Python 3.13 with pydantic 2.13, argon2-cffi 25.1 and bcrypt 5.0.

SQL injection

Formatting user input into SQL lets the input rewrite the query.

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (name TEXT, secret TEXT)")
db.executemany("INSERT INTO users VALUES (?, ?)",
               [("alice", "a-123"), ("bob", "b-456")])

def find_unsafe(name: str):
    sql = f"SELECT name, secret FROM users WHERE name = '{name}'"
    return db.execute(sql).fetchall()

def find_safe(name: str):
    return db.execute("SELECT name, secret FROM users WHERE name = ?", (name,)).fetchall()

attack = "nobody' OR '1'='1"
print("unsafe:", find_unsafe(attack))
print("safe:  ", find_safe(attack))
print("safe:  ", find_safe("alice"))
$ python sql_injection.py
unsafe: [('alice', 'a-123'), ('bob', 'b-456')]
safe:   []
safe:   [('alice', 'a-123')]

The f-string produced WHERE name = 'nobody' OR '1'='1', which is true for every row — every user’s secret leaked. With a ? placeholder the driver sends the value separately from the SQL, so the database looks for a user literally named nobody' OR '1'='1 and finds none. ORMs such as SQLAlchemy build parameterised queries the same way.

Command injection

shell=True hands the whole string to a shell, which treats ; as “run another command”.

import subprocess

filename = "report.txt; echo INJECTED: attacker command ran"

unsafe = subprocess.run(f"echo processing {filename}", shell=True,
                        capture_output=True, text=True)
print("unsafe:", unsafe.stdout.splitlines())

safe = subprocess.run(["echo", "processing", filename],
                      capture_output=True, text=True, check=True, timeout=5)
print("safe:  ", safe.stdout.splitlines())
$ python command_injection.py
unsafe: ['processing report.txt', 'INJECTED: attacker command ran']
safe:   ['processing report.txt; echo INJECTED: attacker command ran']

With the shell, the “filename” became two commands. As a list with no shell, the whole string is one argument to echo and nothing after ; is executed. check=True raises on a non-zero exit and timeout stops a hung command. Here the injected command was a harmless echo; in a real attack it would be anything the server’s user can run.

Path traversal

Joining a user-supplied name onto a base directory is not enough: ../ walks out of it.

import tempfile
from pathlib import Path

root = Path(tempfile.mkdtemp())
uploads = root / "uploads"
uploads.mkdir()
(uploads / "photo.txt").write_text("a photo")
(root / "secrets.txt").write_text("db password")

def read_unsafe(name: str) -> str:
    return (uploads / name).read_text()

def read_safe(name: str) -> str:
    target = (uploads / name).resolve()
    if not target.is_relative_to(uploads.resolve()):
        raise PermissionError(f"access denied: {name}")
    return target.read_text()

print(read_unsafe("photo.txt"), "|", read_unsafe("../secrets.txt"))
print(read_safe("photo.txt"))
try:
    read_safe("../secrets.txt")
except PermissionError as e:
    print(e)
$ python traversal.py
a photo | db password
a photo
access denied: ../secrets.txt

uploads / "../secrets.txt" is a perfectly valid path to a file outside uploads. resolve() normalises the .. (and follows symlinks) so the check sees where the path really points; is_relative_to then rejects anything outside the allowed directory.

Validating input

Check untrusted data at the boundary and reject it with a clear message. The standard library is enough for simple rules:

import re

USERNAME = re.compile(r"[a-zA-Z0-9_]{3,50}")
RESERVED = {"admin", "root", "system"}

def validate_username(value: object) -> str:
    if not isinstance(value, str):
        raise TypeError("username must be a string")
    value = value.strip()
    if not USERNAME.fullmatch(value):
        raise ValueError("username must be 3-50 letters, digits or _")
    if value.lower() in RESERVED:
        raise ValueError("username is reserved")
    return value

for candidate in ["alice_99", "  bob  ", "a", "Admin", "x; DROP", 42]:
    try:
        print("ok:", repr(validate_username(candidate)))
    except (TypeError, ValueError) as e:
        print(f"rejected {candidate!r}: {e}")
$ python validate.py
ok: 'alice_99'
ok: 'bob'
rejected 'a': username must be 3-50 letters, digits or _
rejected 'Admin': username is reserved
rejected 'x; DROP': username must be 3-50 letters, digits or _
rejected 42: username must be a string

fullmatch matters: re.match would accept "alice_99; DROP" because it only anchors at the start. The reserved check is case-insensitive so Admin can’t sneak past. For larger structures, pydantic (pip install pydantic) declares the same rules as types:

from typing import Annotated
from pydantic import BaseModel, Field, ValidationError

class Registration(BaseModel):
    username: Annotated[str, Field(pattern=r"^[a-zA-Z0-9_]{3,50}$")]
    age: Annotated[int, Field(ge=13, le=120)]

print(Registration(username="alice_99", age="25"))
try:
    Registration(username="a b", age=7)
except ValidationError as e:
    for err in e.errors():
        print(err["loc"][0], "->", err["msg"])
$ python validate_pydantic.py
username='alice_99' age=25
username -> String should match pattern '^[a-zA-Z0-9_]{3,50}$'
age -> Input should be greater than or equal to 13

The string "25" was converted to the int 25; invalid input produced one error per field, all reported at once.

Tokens: secrets, not random

random is predictable by design; secrets uses the operating system’s secure source.

import random
import secrets
import string

random.seed(42)
first = [random.randint(0, 9) for _ in range(5)]
random.seed(42)
print("random repeats itself:", first == [random.randint(0, 9) for _ in range(5)])

token = secrets.token_urlsafe(32)
print(len(token), set(token) <= set(string.ascii_letters + string.digits + "-_"))
print(len(secrets.token_hex(16)), len(secrets.token_bytes(32)))

alphabet = string.ascii_letters + string.digits
password = "".join(secrets.choice(alphabet) for _ in range(20))
print(len(password), secrets.token_urlsafe(32) == token)
$ python tokens.py
random repeats itself: True
43 True
32 32
20 False

Anyone who learns random’s state — or, with enough outputs, reconstructs it — can predict every future value; that’s fine for simulations, fatal for session IDs. The tokens themselves are different every run, so the example prints only their properties: 32 random bytes become 43 URL-safe characters or 32 hex digits from 16 bytes.

Password hashing

A password hash must be salted and deliberately slow. Argon2 (pip install argon2-cffi) is the modern default.

import hashlib
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

print(hashlib.sha256(b"hunter2").hexdigest()[:16])
print(hashlib.sha256(b"hunter2").hexdigest()[:16])

ph = PasswordHasher()
h1 = ph.hash("correct-horse-battery-staple")
h2 = ph.hash("correct-horse-battery-staple")
print(h1.split("$")[1:4], h1 == h2)
print(ph.verify(h1, "correct-horse-battery-staple"))
try:
    ph.verify(h1, "wrong-password")
except VerifyMismatchError:
    print("wrong password rejected")
$ python passwords.py
f52fbd32b2b3b86f
f52fbd32b2b3b86f
['argon2id', 'v=19', 'm=65536,t=3,p=4'] False
True
wrong password rejected

Plain SHA-256 gives the same digest for the same password every time, so one precomputed table cracks every user who chose hunter2, and it’s fast enough to try billions of guesses a second. Argon2 salted each hash randomly — the same password produced two different hashes — and its parameters (64 MiB of memory, 3 passes) make each guess expensive. The hash string stores those parameters and the salt, so verify needs nothing else. bcrypt.hashpw/bcrypt.checkpw follow the same pattern.

Checksums and HMAC

hashlib is the right tool for integrity checks; hmac adds a secret key so only key holders can produce a valid tag.

import hashlib
import hmac

print(hashlib.sha256(b"important data").hexdigest())

key = b"server-side-secret"
message = b"user_id=42&role=user"
tag = hmac.new(key, message, hashlib.sha256).hexdigest()

def verify(message: bytes, provided: str) -> bool:
    expected = hmac.new(key, message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, provided)

print(verify(message, tag))
print(verify(b"user_id=42&role=admin", tag))
$ python hmac_demo.py
00ccefe696cfc74ba50650135901fcb2ab6a0cdf8fa9cdf5bbcb6596fa32f4e6
True
False

A digest changes completely if one byte of the input changes. The HMAC tag verified for the original message, but tampering with role made verification fail — and an attacker can’t compute a new tag without the key. compare_digest compares in constant time, so response timing doesn’t reveal how many characters of a guess were right.

Secrets from the environment

Keep secrets out of source code; read them from the environment (or a secrets manager) and fail fast when they’re missing.

import os
import sys

def require(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        sys.exit(f"config error: {name} is not set")
    return value

secret_key = require("SECRET_KEY")
print("loaded SECRET_KEY of length", len(secret_key))
$ python config.py
config error: SECRET_KEY is not set
$ SECRET_KEY=s3cr3t-value python config.py
loaded SECRET_KEY of length 12

The program refused to start without its key instead of running with a blank one, and it never printed the secret itself. In development, python-dotenv can load a .env file into os.environ — keep .env in .gitignore and commit a .env.example with placeholder values instead.

Checklist

  • Parameterised SQL, never string-formatted SQL.
  • subprocess.run([...]) with a list; avoid shell=True with any external input.
  • resolve() + is_relative_to() before opening user-named files.
  • Validate input at the boundary (fullmatch, pydantic).
  • secrets for tokens; hmac.compare_digest to compare them.
  • Argon2 or bcrypt for passwords — never plain hashes.
  • Secrets from the environment, never committed.
  • Pin dependencies and scan them with pip-audit.

Practice

1. Rewrite find_unsafe from the SQL example to use a named parameter (:name) and show the attack string returns nothing.

Solution
import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (name TEXT, secret TEXT)")
db.execute("INSERT INTO users VALUES ('alice', 'a-123')")

def find(name: str):
    return db.execute("SELECT * FROM users WHERE name = :name", {"name": name}).fetchall()

print(find("nobody' OR '1'='1"), find("alice"))
$ python named_params.py
[] [('alice', 'a-123')]

2. Show that the path-traversal check also blocks an absolute path such as /etc/passwd.

Solution
import tempfile
from pathlib import Path

uploads = Path(tempfile.mkdtemp()).resolve()

def check(name: str) -> str:
    target = (uploads / name).resolve()
    return "allowed" if target.is_relative_to(uploads) else "denied"

for name in ["photo.txt", "sub/../photo.txt", "../x", "/etc/passwd"]:
    print(f"{name:18} {check(name)}")
$ python traversal_abs.py
photo.txt          allowed
sub/../photo.txt   allowed
../x               denied
/etc/passwd        denied

Joining an absolute path with / discards the base entirely — uploads / "/etc/passwd" is just /etc/passwd — so the containment check is essential.

3. Generate a 6-digit numeric one-time code with secrets, keeping leading zeros.

Solution
import secrets

codes = [f"{secrets.randbelow(10**6):06d}" for _ in range(1000)]
print(all(len(c) == 6 and c.isdigit() for c in codes))
$ python otp.py
True

randbelow(10**6) gives 0–999999 uniformly; :06d pads short values like 42 to 000042. The codes themselves change every run, so only their shape is printed.

4. Use hmac to sign a cookie value as value.tag, and write load_cookie that returns the value only if the tag is valid.

Solution
import hashlib
import hmac

KEY = b"server-side-secret"

def sign(value: str) -> str:
    tag = hmac.new(KEY, value.encode(), hashlib.sha256).hexdigest()[:16]
    return f"{value}.{tag}"

def load_cookie(cookie: str) -> str | None:
    value, _, tag = cookie.rpartition(".")
    expected = sign(value).rpartition(".")[2]
    return value if hmac.compare_digest(tag, expected) else None

cookie = sign("user=42")
print(cookie)
print(load_cookie(cookie), load_cookie(cookie.replace("42", "1")))
$ python signed_cookie.py
user=42.d9246fef35734e4c
user=42 None

Changing the user id invalidates the tag, so the forged cookie is rejected.

Frequently Asked Questions

How do I store passwords in Python?
Never store plain text or a fast hash such as MD5 or SHA-256. Use a password hashing algorithm designed to be slow and salted, such as Argon2 (argon2-cffi) or bcrypt, store only its output, and verify with the library's verify function.
What's the difference between secrets and random?
random uses a deterministic Mersenne Twister generator meant for simulations; its future output can be predicted from past output. secrets draws from the operating system's cryptographically secure source and is the module to use for tokens, passwords and keys.
How do I prevent SQL injection in Python?
Pass user input as query parameters, using ? or :name placeholders with cursor.execute(sql, params), or use an ORM that does so. Never build SQL with f-strings, % or + from untrusted values, because the database will execute whatever SQL those values contain.
Why use hmac.compare_digest instead of == for tokens?
== can return as soon as it finds the first differing character, so the time it takes leaks how much of a guess was correct. hmac.compare_digest takes the same time regardless of where the strings differ, closing that timing side channel.
Is subprocess.run safe with user input?
It is safe when you pass the command as a list of arguments and leave shell=False, the default: the input is then a single argument, never parsed by a shell. With shell=True, characters such as ; and $( ) in user input let an attacker run arbitrary commands.