Python · lesson 28 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Also 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; avoidshell=Truewith any external input.resolve()+is_relative_to()before opening user-named files.- Validate input at the boundary (
fullmatch, pydantic). secretsfor tokens;hmac.compare_digestto 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.