When building web tools and APIs with Python, most developers focus on feature implementation. But in real-world projects, security design quality equals service quality. A service lacking user data protection, attack resistance, and operational safety will never earn trust—no matter how rich its features.
This article covers 10 security patterns every Python developer should implement at a minimum, complete with NG (bad) and OK (good) code examples. Whether you use Django, FastAPI, or write Python scripts, this guide is for you.
For foundational knowledge on passwords, hashes, and tokens, see “Understanding Passwords, UUIDs, Hashes & Tokens.”
Overview: 10 Python Security Patterns
Let’s start with the big picture. Here are all 10 patterns at a glance, with links to each section.
| Pattern | Purpose | Priority | OWASP Mapping |
|---|---|---|---|
| ① Secure Random Generation | Unpredictable token generation | Required | A02 Cryptographic Failures |
| ② Password Storage | Credential protection | Required | A02 Cryptographic Failures |
| ③ Secure JWT Design | Auth control | Required | A07 Auth Failures |
| ④ SQL Injection Prevention | Database protection | Required | A03 Injection |
| ⑤ XSS Prevention | Display safety | Required | A03 Injection |
| ⑥ CSRF Protection | Form submission safety | Required | A01 Broken Access Control |
| ⑦ Rate Limiting | Abuse prevention | Important | — |
| ⑧ Log Design | Incident prevention | Important | A09 Logging Failures |
| ⑨ Secret Management | Leak prevention | Required | A02 Cryptographic Failures |
| ⑩ Environment-Based Config | Operational safety | Required | A05 Security Misconfiguration |
We’ve included OWASP Top 10 mappings as well. Covering these 10 patterns alone addresses the majority of risks identified by OWASP.
① Secure Random Generation — Never Use random
When generating tokens, auth codes, or password reset URLs, Python’s random module must never be used. random uses the Mersenne Twister algorithm, which is predictable—an attacker who observes a few hundred outputs can predict future values.
Bad example:
import random
token = random.randint(100000, 999999) # Predictable!
Good example:
import secrets
token = secrets.token_hex(16)
print(token)
# Example: 9f3c0c6d61c3c2e7b2c5a2b41a6f9d88
The secrets module uses the OS’s cryptographically secure pseudorandom number generator (CSPRNG), making output prediction practically impossible. Use secrets for all security-related randomness: tokens, CSRF protection, password resets.
secrets.token_urlsafe(32) generates URL-safe tokens, perfect for password reset links. See “Python Password Generator Patterns” for more details.
② Password Storage — Plain Text Is Unacceptable
Storing passwords in plain text is one of the most catastrophic security failures. The moment your database leaks, every user’s password is in the attacker’s hands.
Bad example:
password = "user_password"
db.save(password) # Plain text → instant breach
Good example:
import bcrypt
password = b"mypassword"
hashed = bcrypt.hashpw(password, bcrypt.gensalt())
# Verify
is_valid = bcrypt.checkpw(password, hashed)
print(is_valid) # True
pip install bcrypt
Using general-purpose hash functions like SHA-256 alone is also dangerous. SHA-256 is too fast—attackers can try billions of hashes per second. bcrypt and Argon2 are intentionally slow, making brute-force attacks orders of magnitude more expensive.
“I hashed with SHA-256, so it’s secure” is wrong. Always use intentionally slow algorithms like bcrypt, Argon2, or scrypt for password storage. For hash fundamentals, see “Understanding Passwords, UUIDs, Hashes & Tokens.”
③ Secure JWT Design
JWT (JSON Web Token) is widely used for stateless authentication, but misconfiguration leads to serious vulnerabilities.
Bad example:
import jwt
token = jwt.encode({"user_id": 1}, "secret") # No expiry, weak secret
Good example:
import jwt
import datetime
payload = {
"user_id": 1,
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)
}
token = jwt.encode(payload, "STRONG_SECRET_KEY_HERE", algorithm="HS256")
# Verify
data = jwt.decode(token, "STRONG_SECRET_KEY_HERE", algorithms=["HS256"])
pip install PyJWT
Three rules for secure JWT design:
- Always set an expiration (exp) — tokens without expiry can be exploited forever if leaked
- Explicitly specify the algorithm — omitting it risks “none” algorithm attacks
- Use a strong secret key — generate with
secrets.token_hex(32)and store in environment variables
Access token expiry should be 15 minutes to 1 hour. For persistent login, combine with refresh tokens and keep access tokens short-lived.
④ SQL Injection Prevention
SQL injection is one of the oldest yet still most common vulnerabilities. Concatenating user input directly into SQL statements lets attackers manipulate your database at will.
Bad example:
# Never do this
query = "SELECT * FROM users WHERE name='" + name + "'"
cursor.execute(query)
If name contains admin' OR '1'='1, all user data gets exposed.
Good example:
# Use parameter binding
cursor.execute("SELECT * FROM users WHERE name = %s", (name,))
With parameter binding, user input is safely treated as data, not as part of the SQL statement. Django ORM and SQLAlchemy use this approach by default, but always use parameter binding when writing raw SQL.
f-strings (f"SELECT ... WHERE name='{name}'") are just as dangerous as string concatenation. They may look cleaner, but the SQL injection risk is identical.
⑤ XSS Prevention
XSS (Cross-Site Scripting) occurs when user input is rendered directly in HTML, allowing malicious scripts to execute in the browser.
Bad example:
# Returning user input as-is
return user_input
# If attacker inputs <script>alert(1)</script> it executes
Good example:
import html
safe_output = html.escape(user_input)
return safe_output
# <script> → <script> — neutralized
The principle of XSS prevention is “never trust output”. Escape at the output stage (when rendering HTML), not at the input stage. Django templates and Jinja2 enable HTML escaping by default, but be especially careful when using |safe filters or mark_safe().
When using {{ variable|safe }} in Django templates, only apply it to trusted data. Applying |safe to user input is essentially opening the door to XSS yourself.
⑥ CSRF Protection
CSRF (Cross-Site Request Forgery) tricks authenticated users into sending unintended requests. The defense is embedding server-issued tokens in forms and validating them on submission.
Django (built-in):
<form method="POST">
{% csrf_token %}
<input type="text" name="data">
<button type="submit">Submit</button>
</form>
FastAPI:
from itsdangerous import URLSafeSerializer
s = URLSafeSerializer("secret-key")
csrf_token = s.dumps("session_id")
# Verify
try:
data = s.loads(received_token)
except Exception:
raise HTTPException(status_code=403)
pip install itsdangerous
Django has CSRF protection enabled by default—as long as you include {% csrf_token %}, you’re covered. For frameworks without built-in CSRF protection like FastAPI, you’ll need to implement token generation and validation yourself.
⑦ Rate Limiting
Rate limiting restricts the number of requests within a given time window. It prevents login brute-force attacks, API abuse, and bot flooding.
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@app.get("/api/data")
@limiter.limit("10/minute")
def get_data():
return {"status": "ok"}
pip install slowapi
For Django, django-ratelimit is the standard choice. Regardless of framework, always set rate limits on these endpoints:
- Login — direct target of brute-force attacks
- Password reset — prevents email bombing
- API endpoints — prevents abuse and cost escalation
- Registration forms — prevents spam account creation
Rate limiting doesn’t “completely prevent” attacks—it dramatically increases the cost of attacking. Combining it with Nginx-level IP-based rate limiting blocks requests before they even reach your application layer.
⑧ Log Design — What You Must Never Log
Logging is essential for debugging and security monitoring, but there’s information that must never appear in logs. If passwords, tokens, API keys, or session IDs end up in log files, those files become attack vectors.
Bad example:
# Never do this
print(f"Login: user={username}, password={password}")
logging.info(f"Token: {api_token}")
Good example:
import logging
logging.info("Login attempt: user=%s, ip=%s", username, request_ip)
logging.warning("Failed login: user=%s, ip=%s", username, request_ip)
The rules are simple:
- Never log: passwords, tokens, API keys, cookies, personal data
- Do log: user IDs, IP addresses, action names, timestamps, outcomes (success/failure)
Deploying with DEBUG=True in production exposes stack traces, environment variables, and database connection details in error pages. In Django, always set DEBUG=False in production.
⑨ Secret Management
API keys, database passwords, JWT secret keys—these secrets must never be hardcoded in source code. The moment your code appears on GitHub, everything leaks.
Bad example:
# Hardcoded → instant leak on Git push
SECRET_KEY = "abc123superSecret"
DB_PASSWORD = "production_password"
Good example:
import os
from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = os.getenv("SECRET_KEY")
DB_PASSWORD = os.getenv("DB_PASSWORD")
pip install python-dotenv
Example .env file:
SECRET_KEY=9f3c0c6d61c3c2e7b2c5a2b41a6f9d88
DB_PASSWORD=strong_random_password_here
And always add .env to your .gitignore.
GitHub has “Secret Scanning” that auto-detects committed API keys from major services. However, attackers may scan faster. The best defense is to never commit secrets in the first place.
⑩ Environment-Based Configuration
Using the same configuration across development, staging, and production environments invites accidents. Debug mode enabled in production, test database connections manipulating live data—these are real incidents.
import os
ENV = os.getenv("ENV", "development")
if ENV == "production":
DEBUG = False
ALLOWED_HOSTS = ["example.com"]
else:
DEBUG = True
ALLOWED_HOSTS = ["*"]
Separating configuration through environment variables lets you switch behavior per environment without changing code. For Django, django-environ adds type-safe management; for FastAPI, pydantic-settings provides similar benefits.
Security Checklist & Common Incidents
Run through this checklist before every release:
- □ Using
secretsfor random generation? - □ Passwords hashed with bcrypt/Argon2?
- □ JWT has expiration (exp) set?
- □ SQL uses parameter binding?
- □ HTML output is escaped?
- □ Forms include CSRF tokens?
- □ Login/API has rate limiting?
- □ Logs free of passwords/tokens?
- □ Secrets managed via environment variables?
- □ DEBUG=False in production?
Also be aware of the most common security incidents in practice:
| Rank | Incident | Countermeasure |
|---|---|---|
| #1 | Secrets pushed to Git | .env + .gitignore |
| #2 | DEBUG enabled in production | Environment-based config |
| #3 | SQL string concatenation | Parameter binding |
| #4 | JWT without expiry | Mandatory exp setting |
| #5 | No rate limiting | slowapi / django-ratelimit |
Preventing just these five covers the vast majority of security incidents encountered in practice.
FAQ
Q: What’s the bare minimum for Python security?
secrets (secure random), bcrypt (password storage), environment variables (secret management), parameter binding (SQL safety), and html.escape (XSS prevention). These five alone create a massive gap compared to unsecured applications.
Q: Is Django secure by default?
Django’s defaults are strong—CSRF tokens, XSS escaping, and SQL injection prevention are all built in. However, misconfiguration is common: leaving DEBUG=True in production, misusing |safe filters, and careless raw SQL usage are frequent sources of vulnerabilities.
Q: Is JWT always necessary?
No. For small web apps, session-based authentication works fine. JWT shines in microservice architectures or SPAs (Single Page Applications) where stateless authentication is needed.
Q: Is rate limiting necessary?
For any publicly accessible API or login form, yes. It may not be needed for internal scripts, but public endpoints without rate limiting are prime targets for bots and brute-force attacks.
Q: What’s the most common security incident?
Secrets (API keys, passwords) pushed to GitHub. Bots constantly scan GitHub for API keys, and exploitation can happen within minutes of exposure.
Conclusion
Building secure web tools in Python doesn’t require advanced cryptography—it requires mastering the fundamentals. Among the 10 patterns covered, here are the top 5:
- Generate secure random values with
secrets - Hash passwords with bcrypt
- Manage secrets via environment variables
- Use parameter binding for SQL safety
- Prevent XSS with
html.escape
Security is not something you bolt on later—it’s something you design from the start. This principle is the foundation of professional Python development.

Leave a Reply