Secure your Python applications the practical way. This guide walks through the python security best practices every developer should know: validate user input, manage secrets with environment variables, scan dependencies for vulnerabilities, prevent SQL injection, hash passwords with bcrypt, and apply the OWASP (Open Web Application Security Project) basics.
“Security is not a product, but a process.”
Bruce Schneier, Applied Cryptography
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 15 minutes
Python security is the thing most developers think about last, and the thing attackers go after first. Here is the good news: you do not need to become a security researcher to write safe code. You need four habits. Never trust what a user types. Never paste secrets into your source. Never ship dependencies with known holes. Never store a password as plain text. Those four habits stop the large majority of the attacks you will ever face.
Think of your application like your house. Input validation is the front door lock. Secrets management is not leaving the spare key under the doormat. Dependency scanning is checking that the locks you bought were not already cracked at the factory. Password hashing is keeping the guest list in a safe instead of taped to the window. No single one of these makes you safe. Together they make you a hard target, and attackers move on to easier ones.
This post is practical, not theoretical. Each section is one real vulnerability followed by the exact fix you can paste into your code today. Pravin, a backend developer two years into his career, once ran pip-audit on a production service and found three dependencies with critical CVEs (Common Vulnerabilities and Exposures). One had a known remote code execution bug that had been public for six months. The fix was a single pip install --upgrade. The scary part was not the fix. It was how long the door had been standing open without anyone noticing.
The diagram shows Python security as layers, not a single switch you flip. Input validation guards the boundary where user data enters. Secrets management keeps your credentials out of the code. Dependency scanning watches the third-party packages you did not write. Each layer catches a different kind of attack. Input validation stops injection, secrets management prevents credential leaks, and dependency scanning catches known vulnerabilities in the libraries you pulled in. Treating security as layers rather than one checkbox is the line between a production-grade application and a tutorial demo.
Table of Contents
Prerequisites
You should have worked through most of Part 3 before this one. It helps to be comfortable with the exception handling tutorial (we raise a lot of errors here), virtual environments tutorial (so you can install scanners cleanly), and the SQLite tutorial (the SQL injection section builds on it). The bcrypt example needs one install: pip install bcrypt.
Pattern 1: Never Trust User Input
Every value that comes from outside your program is a guest you have not met yet. A username from a signup form, a filename from an upload, a search box, a URL parameter: all of it is typed by someone you cannot see, and some of those people are not friendly. The pattern is simple. Decide exactly what good input looks like, accept only that, and reject everything else. This is called allowlisting, and it beats trying to guess every bad input you should block.
📄 input_validation.py: validate and sanitize everything
import re
from pathlib import Path
def validate_username(username: str) -> str:
"""Validate and sanitize a username."""
username = username.strip()
if not username:
raise ValueError("Username cannot be empty")
if len(username) > 50:
raise ValueError("Username too long (max 50 characters)")
if not re.match(r"^[a-zA-Z0-9_-]+$", username):
raise ValueError("Username can only contain letters, numbers, _ and -")
return username
def safe_file_path(user_input: str, base_dir: str = "/app/uploads") -> Path:
"""Prevent path traversal attacks."""
base = Path(base_dir).resolve()
target = (base / user_input).resolve()
# Ensure the target is inside the base directory
if not target.is_relative_to(base):
raise ValueError("Path traversal detected!")
return target
# Examples
print(validate_username("viraj_patil")) # OK
# validate_username("../../../etc/passwd") # Raises ValueError
# safe_file_path("../../etc/passwd") # Raises ValueError
▶ Output
viraj_patil
What happened here: validate_username strips whitespace, checks the length, then runs a regex that allows only letters, numbers, underscores, and hyphens. When a new user named Viraj signs up with the handle viraj_patil, it passes straight through. Anything sneaky, like ../../../etc/passwd, hits the regex and raises a ValueError before it can do harm. The second function, safe_file_path, fights a classic trick called path traversal: a user passes ../../etc/passwd hoping to climb out of your upload folder and read system files.
We resolve the path to its real absolute location with resolve(), then use is_relative_to() to confirm it still sits inside the base directory. If it does not, we refuse it. The rule behind both functions is the same: describe what is allowed and reject the rest, rather than playing whack-a-mole with every bad string you can imagine.
Pattern 2: Secrets Management
A secret is anything that would ruin your day if it leaked: a database password, an API (Application Programming Interface) key, a signing key. The most common way these leak is the laziest one. Someone types the key straight into a Python file, commits it, and pushes to GitHub. Bots scan public repositories for exactly this within minutes. Treat a hardcoded secret like writing your ATM PIN on the back of your debit card. The fix is to keep secrets out of the code entirely and read them from the environment at runtime.
📄 secrets_management.py: never hardcode credentials
import os
from dotenv import load_dotenv
# Load .env file (development only)
load_dotenv()
# Read secrets from environment variables
DATABASE_URL = os.environ["DATABASE_URL"] # Crashes if missing (good!)
API_KEY = os.environ.get("API_KEY", "") # Empty string if missing
SECRET_KEY = os.environ["SECRET_KEY"]
# Generate cryptographically secure tokens
import secrets
token = secrets.token_urlsafe(32) # For session tokens, reset links
print(f"Generated token: {token}")
📄 .env: development secrets (NEVER commit this file)
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp API_KEY=sk-abc123 SECRET_KEY=super-secret-key-change-in-production
📄 .gitignore: keep secrets out of git
.env *.pem *.key credentials.json
▶ Output (token is random, yours will differ)
Generated token: gdITtud0GeTiHT9n7DCZaM2csP0Pg4vaJY7obkX1KA0
What happened here: Notice the small but important difference between the two reads. os.environ["DATABASE_URL"] uses square brackets, so if that variable is missing the program crashes immediately with a KeyError. That is exactly what you want for a required secret. You would rather fail loudly at startup than run a half-configured app in production. The optional API_KEY uses .get(..., ""), which returns an empty string instead of crashing. The .env file holds these values during local development, and load_dotenv() reads them into the environment.
The single most important line in this whole section is the .env entry in .gitignore. That one line is what stops your secrets from ever reaching GitHub. Finally, secrets.token_urlsafe(32) generates a random, hard to guess token suitable for session IDs and password reset links. Reach for the secrets module here, never random, because random is predictable and was never built for security.
Pattern 3: Prevent SQL Injection
SQL injection is the oldest trick in the book and it still works because people keep building queries by gluing strings together. Here is the everyday picture. Imagine a security guard who reads a handwritten note out loud and does whatever it says. You hand him a note that reads “let my friend in,” and he does. That is string concatenation. Now imagine a guard who checks names against a guest list and never reads instructions off the note. That is a parameterized query. The data stays data, and it never gets a chance to become a command.
📄 sql_injection.py: parameterized queries
import sqlite3
# VULNERABLE: SQL injection!
def get_user_bad(username):
conn = sqlite3.connect("app.db")
# An attacker can input: ' OR '1'='1
query = f"SELECT * FROM users WHERE username = '{username}'"
return conn.execute(query).fetchone()
# SAFE: parameterized query
def get_user_safe(username):
conn = sqlite3.connect("app.db")
query = "SELECT * FROM users WHERE username = ?"
return conn.execute(query, (username,)).fetchone()
# With SQLAlchemy ORM, safe by default
# user = session.query(User).filter(User.username == username).first()
What happened here: Look at get_user_bad. If a user types ' OR '1'='1 as the username, the final query becomes SELECT * FROM users WHERE username = '' OR '1'='1', which is always true, so it hands back the first user in the table. With a nastier input an attacker can read or delete whole tables. The fixed version, get_user_safe, uses a ? placeholder and passes the value separately in a tuple.
The database now treats the username strictly as data, never as part of the SQL command, so injection simply cannot happen. Parameterized queries are not a clever optimization you turn on for important queries. Use them for every single query that touches user input, no exceptions. If you use an ORM (Object-Relational Mapping) like SQLAlchemy, it parameterizes for you behind the scenes, which is one more reason to lean on it.
Pattern 4: Password Hashing
One day a database gets stolen. It happens to companies far bigger than yours. The only question that matters then is what the attacker finds inside the password column. If it is plain text, every account is gone in seconds, and worse, so is every account where that person reused the same password. Hashing is a one way blender. You can turn a password into a hash, but you cannot turn the hash back into the password.
When a user logs in, you blend what they typed and compare the two hashes. You never need to store the real password at all. In the example below, a user named Niranjan signs up with a password, and we store only its hash.
📄 password_hashing.py: bcrypt for secure passwords
import bcrypt
def hash_password(password: str) -> str:
"""Hash a password with bcrypt (includes a random salt)."""
salt = bcrypt.gensalt(rounds=12) # Work factor: higher means slower and safer
hashed = bcrypt.hashpw(password.encode(), salt)
return hashed.decode()
def verify_password(password: str, hashed: str) -> bool:
"""Verify a password against its hash."""
return bcrypt.checkpw(password.encode(), hashed.encode())
# Usage
stored_hash = hash_password("niranjan_secure_2026")
print(f"Hash: {stored_hash}")
print(f"Correct password: {verify_password('niranjan_secure_2026', stored_hash)}")
print(f"Wrong password: {verify_password('wrong_password', stored_hash)}")
▶ Output (hash is random, yours will differ)
Hash: $2b$12$ZP4LgBXHZslhMYKB0h9bme1ZD0uaYnKHe8GlGgjjSZDYwNNoE0/Si Correct password: True Wrong password: False
What happened here: Run this twice and you will get two different hashes for the same password. That is the random salt at work, and it is a feature. The salt is mixed into the hash, which is why the hashed string starts with $2b$12$ and then carries the salt and the result together. A salt stops an attacker from using a precomputed lookup table (called a rainbow table) to reverse common passwords in bulk.
The rounds=12 setting is the work factor. Each extra round roughly doubles the time it takes to compute one hash. You barely notice that on a single login, but it makes brute forcing millions of guesses painfully slow for an attacker. Never store passwords in plain text, and never reach for MD5 or SHA-256 here. Those are fast by design, and for passwords fast is exactly wrong. bcrypt was built to be slow on purpose, which is precisely what you want.
Pattern 5: Dependency Scanning
Most of the code in a real Python app is not code you wrote. It is the dependencies you installed, and their dependencies, and theirs. A typical project pulls in dozens of packages without a second thought. Any one of them can have a known security hole that was discovered and published after you installed it. You would never buy a car and ignore a recall notice. A dependency scanner is the recall notice for your packages. It checks every installed version against public vulnerability databases and tells you which ones to upgrade.
📄 Terminal: scan for vulnerable dependencies
# pip-audit scans installed packages against vulnerability databases pip install pip-audit pip-audit # Safety is an alternative scanner (the old "safety check" command is deprecated) pip install safety safety scan # GitHub Dependabot opens automatic PRs for vulnerable deps (configure in repo settings)
▶ Output (scanning an old requests==2.25.0, real pip-audit 2.10.1)
Found 9 known vulnerabilities in 3 packages Name Version ID Fix Versions -------- ------- -------------- ------------ requests 2.25.0 PYSEC-2023-74 2.31.0 requests 2.25.0 CVE-2024-35195 2.32.0 requests 2.25.0 CVE-2024-47081 2.32.4 requests 2.25.0 CVE-2026-25645 2.33.0 idna 2.10 PYSEC-2024-60 3.7 idna 2.10 PYSEC-2026-215 3.15 urllib3 1.26.20 CVE-2025-50181 2.5.0 urllib3 1.26.20 CVE-2025-66418 2.6.0 urllib3 1.26.20 CVE-2026-21441 2.6.3
What happened here: Pinning requests==2.25.0 looked harmless, but the scan found real, published vulnerabilities not just in requests itself but in idna and urllib3, two libraries it quietly pulls in. That is the whole point: the danger usually hides in the dependencies of your dependencies, the ones you never chose by name. Each row gives you the package, the broken version, the vulnerability ID you can look up, and the version that fixes it. The fix is almost always a quick upgrade. Run pip-audit on every project, and add it to your CI (Continuous Integration) pipeline so a vulnerable package can never sneak into production without someone seeing this exact table first.
Security Checklist for Every Python Project
Pilots do not skip the pre-flight checklist just because they have flown a thousand times, and neither should you. Treat this python security checklist the same way: print it out, or keep it open in a tab. Before you ship anything, walk down the list and tick each box. None of these takes long, and skipping any one of them is how breaches happen.
- ☐ All user input validated and sanitized
- ☐ Secrets in environment variables, not code
- ☐ .env and credential files in .gitignore
- ☐ Parameterized queries for ALL database access
- ☐ Passwords hashed with bcrypt (never plain text, never MD5)
- ☐ Dependencies scanned with pip-audit (weekly or in CI)
- ☐ HTTPS everywhere (no HTTP in production)
- ☐ debug=False in production (Flask/Django)
Frequently Asked Questions
What is SQL injection?
An attack where malicious SQL code is inserted into a query through user input. If you build queries with string concatenation (f"SELECT ... WHERE name = '{user_input}'"), an attacker can inject arbitrary SQL. The fix is to always use parameterized queries.
Why not use SHA-256 for passwords?
SHA-256 is fast, so an attacker can try billions of hashes per second with a GPU. bcrypt is intentionally slow (with configurable rounds), which makes brute-force attacks impractical. SHA-256 is for data integrity, not passwords.
What is pip-audit?
A tool by the Python Packaging Authority (PyPA) that checks your installed packages against known vulnerability databases (OSV, CVE). Run it regularly or add it to your CI pipeline.
How do I handle secrets in production?
Use your cloud provider’s secrets manager, such as AWS Secrets Manager, Google Secret Manager, or Azure Key Vault. For simpler deployments, use environment variables set in your deployment configuration (Docker, systemd, and so on). Never commit secrets to git, even in private repos.
What is OWASP?
The Open Web Application Security Project, a nonprofit that publishes the OWASP Top 10: a list of the most critical web application security risks. It covers injection, broken authentication, sensitive data exposure, vulnerable components, and more.
Try It Yourself
Pick a real project you already have and give it a python security pass. Run pip-audit and upgrade anything it flags. Add input validation to at least one place where users type data. Hunt down any hardcoded secret and move it into an environment variable, then add .env to your .gitignore so it can never be committed. If you store passwords anywhere as plain text or MD5, switch them to bcrypt. Do all five and your project is already safer than most of what ships to production.
Part 3: Professional Python, Complete!
Congratulations. You have finished Part 3, which is 33 posts covering testing, type hints, databases, web scraping, web frameworks, concurrency, automation, GUI programming, packaging, CI/CD, code quality, and security. In this post alone you learned the five python security habits that stop most real-world attacks: validate every input, keep secrets in the environment, parameterize every query, hash passwords with bcrypt, and scan your dependencies. You now have the professional Python toolkit. Next up is Python Profiling, where you learn to find slow code with cProfile and py-spy. And if you want to revisit anything or jump ahead, the full index lives at the Python + AI/ML tutorial series home.
Interview Questions on Python Security
If you can walk through these without peeking, you are ready for this topic in an interview.
Q: You accidentally committed an API key to a public GitHub repository, then force-pushed to remove it from history. Is the key safe now?
No. Treat the key as compromised the moment it hit a public repo, because bots scrape new commits for credentials within minutes, and the old commit can survive in forks, clones, and caches even after a force-push. The correct response is to rotate (revoke and reissue) the key immediately, then clean the history and add the file to .gitignore. Rotation is the fix, history rewriting is only cleanup.
Q: Why must session tokens come from the secrets module instead of the random module?
The random module uses the Mersenne Twister algorithm, which is deterministic: an attacker who observes enough outputs can reconstruct its internal state and predict every future value, including your “random” tokens. The secrets module draws from the operating system’s cryptographically secure random source, which cannot be predicted that way. For anything security-sensitive, like session IDs or password reset links, use secrets.token_urlsafe().
Q: Your service compares a submitted API token to the stored one using ==. A security review flags it. What is the problem?
String comparison with == can return as soon as it finds the first mismatching character, so the response time leaks how many leading characters were correct. An attacker can exploit that timing difference to recover the token one character at a time. Use a constant-time comparison instead: secrets.compare_digest(submitted, stored) or hmac.compare_digest(), which take the same time regardless of where the strings differ.
Q: pip-audit flags a critical vulnerability in urllib3, but urllib3 is not in your requirements.txt. How is that possible, and what do you do?
It is a transitive dependency: a package you did install, such as requests, pulls in urllib3 on its own, so it lives in your environment without appearing in your requirements file. Upgrade the parent package to a version that requires a fixed urllib3, or pin the fixed urllib3 version explicitly, then re-run pip-audit to confirm the finding is gone. This is exactly why scans must cover the full installed environment, not just the packages you named.
Q: A teammate suggests speeding up logins by lowering the bcrypt work factor from 12 to 4. What is the trade-off?
The work factor is what makes bcrypt slow on purpose, and each step roughly doubles the hashing time. Dropping from 12 to 4 makes each hash about 256 times faster, which the user barely notices on one login but which makes an attacker’s offline brute-force attack 256 times cheaper if your hash database ever leaks. Keep the work factor high enough that one hash takes a noticeable fraction of a second on your hardware, and raise it over the years as hardware gets faster.
Q: Is an ORM like SQLAlchemy a complete defense against SQL injection?
Mostly, but not automatically. Normal ORM query methods parameterize values for you, so they are safe by default. The danger returns the moment someone drops down to raw SQL, for example session.execute(text(f"... {user_input}")), and builds the string with user data. The rule from this post still applies inside an ORM: any raw query that touches user input must use bound parameters, never string formatting.
Go deeper: the official Python documentation covers every edge case of this topic.
Related Posts
Previous: AI-Assisted Coding: Cursor, Claude Code, Copilot Workflow
Next: Python Profiling: Find Slow Code with cProfile and py-spy
Series Home: Python + AI/ML Tutorial Series

No comment