10 Python Security Patterns — A Practical Code Guide for Secure Web Tools

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.

PatternPurposePriorityOWASP Mapping
① Secure Random GenerationUnpredictable token generationRequiredA02 Cryptographic Failures
② Password StorageCredential protectionRequiredA02 Cryptographic Failures
③ Secure JWT DesignAuth controlRequiredA07 Auth Failures
④ SQL Injection PreventionDatabase protectionRequiredA03 Injection
⑤ XSS PreventionDisplay safetyRequiredA03 Injection
⑥ CSRF ProtectionForm submission safetyRequiredA01 Broken Access Control
⑦ Rate LimitingAbuse preventionImportant
⑧ Log DesignIncident preventionImportantA09 Logging Failures
⑨ Secret ManagementLeak preventionRequiredA02 Cryptographic Failures
⑩ Environment-Based ConfigOperational safetyRequiredA05 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:

NG
import random
token = random.randint(100000, 999999)  # Predictable!

Good example:

OK
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.

💡 Tip

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:

NG
password = "user_password"
db.save(password)  # Plain text → instant breach

Good example:

OK
import bcrypt

password = b"mypassword"
hashed = bcrypt.hashpw(password, bcrypt.gensalt())

# Verify
is_valid = bcrypt.checkpw(password, hashed)
print(is_valid)  # True
Bash
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.

⚠️ Common pitfall

“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:

NG
import jwt
token = jwt.encode({"user_id": 1}, "secret")  # No expiry, weak secret

Good example:

OK
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"])
Bash
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
💡 Tip

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:

NG
# 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:

OK
# 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.

⚠️ Common pitfall

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:

NG
# Returning user input as-is
return user_input
# If attacker inputs <script>alert(1)</script> it executes

Good example:

OK
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().

💡 Tip

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):

Django Template
<form method="POST">
    {% csrf_token %}
    <input type="text" name="data">
    <button type="submit">Submit</button>
</form>

FastAPI:

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)
Bash
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.

FastAPI + slowapi
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"}
Bash
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
💡 Tip

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:

NG
# Never do this
print(f"Login: user={username}, password={password}")
logging.info(f"Token: {api_token}")

Good example:

OK
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)
⚠️ Common pitfall

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:

NG
# Hardcoded → instant leak on Git push
SECRET_KEY = "abc123superSecret"
DB_PASSWORD = "production_password"

Good example:

OK
import os
from dotenv import load_dotenv

load_dotenv()

SECRET_KEY = os.getenv("SECRET_KEY")
DB_PASSWORD = os.getenv("DB_PASSWORD")
Bash
pip install python-dotenv

Example .env file:

.env
SECRET_KEY=9f3c0c6d61c3c2e7b2c5a2b41a6f9d88
DB_PASSWORD=strong_random_password_here

And always add .env to your .gitignore.

💡 Tip

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.

settings.py
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 secrets for 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:

RankIncidentCountermeasure
#1Secrets pushed to Git.env + .gitignore
#2DEBUG enabled in productionEnvironment-based config
#3SQL string concatenationParameter binding
#4JWT without expiryMandatory exp setting
#5No rate limitingslowapi / 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:

  1. Generate secure random values with secrets
  2. Hash passwords with bcrypt
  3. Manage secrets via environment variables
  4. Use parameter binding for SQL safety
  5. 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.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *