Lesson 13: Security Best Practices
Learning Objectives
By the end of this lesson, you will be able to:
- Identify and prevent OWASP Top 10 vulnerabilities including injection, broken authentication, and security misconfigurations
- Implement secure input validation and sanitization to prevent injection attacks and data corruption
- Design secure authentication and authorization systems using industry-standard practices and frameworks
- Apply encryption and hashing appropriately to protect sensitive data at rest and in transit
- Adopt defensive coding practices that minimize security vulnerabilities and reduce attack surface
Introduction
Security isn’t optional—it’s fundamental to professional software development. A single vulnerability can expose user data, damage reputation, and cost millions in breaches and recovery. In 2023, data breaches cost companies an average of $4.45 million per incident, with compromised credentials being the most common attack vector [1].
The challenge is that security is hard to retrofit. Bolting security onto existing systems is expensive and often ineffective. Security must be designed in from the start, considered at every stage of development, and tested continuously. As the saying goes: “Security is not a product, but a process” [2].
The OWASP (Open Web Application Security Project) Top 10 provides a starting point—a consensus list of the most critical web application security risks. Understanding these vulnerabilities and how to prevent them is essential for every developer. Beyond the Top 10, security encompasses authentication, authorization, encryption, secure configuration, and a defensive coding mindset that assumes attackers will try everything [3].
This lesson covers practical security techniques you can apply immediately. You’ll learn to identify common vulnerabilities, implement proven defenses, and adopt security practices that become second nature. Security isn’t about being paranoid—it’s about being professional [4].
Core Content
OWASP Top 10 Vulnerabilities
The OWASP Top 10 represents the most critical security risks facing web applications. Understanding these vulnerabilities is foundational to secure development [3].
1. Broken Access Control
Users can access resources or perform actions they shouldn’t be authorized for.
# Vulnerable - no authorization check
@app.get("/api/users/{user_id}/orders")
def get_user_orders(user_id: int):
# Anyone can access anyone's orders!
return database.query(Order).filter(Order.user_id == user_id).all()
# Secure - verify authorization
@app.get("/api/users/{user_id}/orders")
def get_user_orders(user_id: int, current_user: User = Depends(get_current_user)):
# Verify current user can access this user's orders
if current_user.id != user_id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Access denied")
return database.query(Order).filter(Order.user_id == user_id).all()2. Cryptographic Failures
Inadequate protection of sensitive data like passwords, credit cards, or personal information.
# Vulnerable - storing plain text passwords
user.password = request_data['password']
database.save(user)
# Secure - hash passwords with strong algorithm
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
# Usage
user.password_hash = hash_password(request_data['password'])
database.save(user)3. Injection
Attackers inject malicious code (SQL, NoSQL, OS commands) that the application executes.
# VULNERABLE - SQL Injection
def get_user(username: str):
query = f"SELECT * FROM users WHERE username = '{username}'"
# Attacker could send: ' OR '1'='1
return database.execute(query)
# SECURE - Parameterized queries
def get_user(username: str):
query = "SELECT * FROM users WHERE username = ?"
return database.execute(query, (username,))
# Or with ORM
def get_user(username: str):
return database.query(User).filter(User.username == username).first()
# VULNERABLE - Command injection
import os
def backup_file(filename: str):
os.system(f"tar -czf backup.tar.gz {filename}")
# Attacker could send: file.txt; rm -rf /
# SECURE - Use safe libraries
import subprocess
def backup_file(filename: str):
subprocess.run(['tar', '-czf', 'backup.tar.gz', filename], check=True)4. Insecure Design
Missing or ineffective security controls from design phase.
# Vulnerable - unlimited password attempts
@app.post("/api/login")
def login(username: str, password: str):
user = get_user(username)
if user and verify_password(password, user.password_hash):
return create_token(user)
return {"error": "Invalid credentials"}
# Secure - rate limiting and account lockout
from fastapi_limiter.depends import RateLimiter
@app.post("/api/login")
@limiter.limit("5/minute") # Max 5 attempts per minute
def login(username: str, password: str):
user = get_user(username)
if user and user.locked_until and user.locked_until > datetime.now():
return {"error": "Account temporarily locked"}
if user and verify_password(password, user.password_hash):
user.failed_attempts = 0
return create_token(user)
if user:
user.failed_attempts += 1
if user.failed_attempts >= 5:
user.locked_until = datetime.now() + timedelta(minutes=30)
database.save(user)
return {"error": "Invalid credentials"}5. Security Misconfiguration
Default configurations, incomplete setups, unprotected files, verbose error messages.
# Vulnerable - verbose error messages
@app.exception_handler(Exception)
async def generic_exception_handler(request, exc):
return JSONResponse(
status_code=500,
content={
"error": str(exc), # Exposes stack traces, database details!
"traceback": traceback.format_exc()
}
)
# Secure - sanitized error messages
@app.exception_handler(Exception)
async def generic_exception_handler(request, exc):
# Log full details internally
logger.error(f"Unhandled exception: {exc}", exc_info=True)
# Return generic message to user
return JSONResponse(
status_code=500,
content={"error": "Internal server error"}
)
# Configuration best practices
# config.py
import os
class Config:
# Never hardcode secrets
SECRET_KEY = os.environ['SECRET_KEY'] # From environment
DATABASE_URL = os.environ['DATABASE_URL']
# Secure defaults
DEBUG = False # Never True in production
SESSION_COOKIE_SECURE = True # HTTPS only
SESSION_COOKIE_HTTPONLY = True # No JavaScript access
SESSION_COOKIE_SAMESITE = 'Lax' # CSRF protection6. Vulnerable and Outdated Components
Using libraries with known vulnerabilities.
# Check for vulnerabilities
# requirements.txt
flask==2.0.1 # Old version with known vulnerabilities
# Use tools to scan dependencies
# $ pip install safety
# $ safety check
# Keep dependencies updated
# requirements.txt
flask==3.0.0 # Latest secure version
# Automate dependency updates
# Use Dependabot, Renovate, or similar tools7. Identification and Authentication Failures
Weak authentication, session management issues, credential exposure.
# Vulnerable - weak session management
@app.post("/api/login")
def login(username: str, password: str):
user = authenticate(username, password)
if user:
session['user_id'] = user.id # Session never expires!
return {"success": True}
# Secure - proper session management
from datetime import datetime, timedelta
import secrets
@app.post("/api/login")
def login(username: str, password: str):
user = authenticate(username, password)
if user:
# Generate secure session token
token = secrets.token_urlsafe(32)
# Store with expiration
session_data = {
'user_id': user.id,
'created_at': datetime.utcnow(),
'expires_at': datetime.utcnow() + timedelta(hours=24),
'ip_address': request.client.host,
'user_agent': request.headers.get('user-agent')
}
cache.set(f"session:{token}", session_data, ttl=86400)
return {"token": token}
# Verify session on each request
def get_current_user(token: str):
session = cache.get(f"session:{token}")
if not session:
raise HTTPException(401, "Invalid or expired session")
if session['expires_at'] < datetime.utcnow():
raise HTTPException(401, "Session expired")
# Verify IP and user agent haven't changed (optional)
if session['ip_address'] != request.client.host:
raise HTTPException(401, "Session hijacking detected")
return get_user(session['user_id'])Input Validation and Sanitization
Never trust user input. Validate everything, sanitize when displaying, and escape when using in queries [2].
Comprehensive Input Validation:
from pydantic import BaseModel, Field, validator, EmailStr
from typing import Optional
import re
class UserRegistration(BaseModel):
username: str = Field(..., min_length=3, max_length=30)
email: EmailStr # Validates email format
password: str = Field(..., min_length=8)
age: int = Field(..., ge=18, le=150)
website: Optional[str] = None
@validator('username')
def username_alphanumeric(cls, v):
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Username must be alphanumeric')
return v
@validator('password')
def password_strength(cls, v):
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain digit')
return v
@validator('website')
def validate_url(cls, v):
if v is None:
return v
if not v.startswith(('http://', 'https://')):
raise ValueError('URL must start with http:// or https://')
return v
@app.post("/api/register")
def register(user: UserRegistration):
# Input is already validated by Pydantic
# Now process safely
hashed_password = hash_password(user.password)
new_user = create_user(user.username, user.email, hashed_password)
return {"user_id": new_user.id}Output Sanitization (XSS Prevention):
import bleach
from markupsafe import escape
# Vulnerable - XSS attack
@app.get("/api/profile/{username}")
def get_profile(username: str):
user = get_user(username)
# If user.bio contains <script>alert('XSS')</script>, it executes!
return {"bio": user.bio}
# Secure - escape HTML
@app.get("/api/profile/{username}")
def get_profile(username: str):
user = get_user(username)
return {"bio": escape(user.bio)}
# For rich text (allow some HTML but sanitize)
ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'u', 'a']
ALLOWED_ATTRIBUTES = {'a': ['href', 'title']}
def sanitize_html(html: str) -> str:
return bleach.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True
)
@app.post("/api/posts")
def create_post(content: str):
sanitized_content = sanitize_html(content)
post = create_post_in_db(sanitized_content)
return {"post_id": post.id}Authentication and Authorization
Secure Password Storage:
from passlib.context import CryptContext
# Use bcrypt with proper cost factor
pwd_context = CryptContext(
schemes=["bcrypt"],
deprecated="auto",
bcrypt__default_rounds=12 # Cost factor (higher = more secure, slower)
)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)JWT Authentication:
from datetime import datetime, timedelta
import jwt
SECRET_KEY = os.environ['JWT_SECRET_KEY']
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def create_access_token(user_id: int) -> str:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {
"sub": str(user_id),
"exp": expire,
"iat": datetime.utcnow(),
"type": "access"
}
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Token expired")
except jwt.JWTError:
raise HTTPException(401, "Invalid token")
# Use in route
@app.get("/api/protected")
def protected_route(token: str = Depends(oauth2_scheme)):
payload = verify_token(token)
user_id = int(payload["sub"])
user = get_user(user_id)
return {"user": user}Role-Based Access Control:
from enum import Enum
class Role(Enum):
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
def require_role(required_role: Role):
def decorator(func):
async def wrapper(*args, current_user: User = Depends(get_current_user), **kwargs):
if not has_role(current_user, required_role):
raise HTTPException(403, "Insufficient permissions")
return await func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator
def has_role(user: User, required_role: Role) -> bool:
role_hierarchy = {
Role.USER: 1,
Role.MODERATOR: 2,
Role.ADMIN: 3
}
return role_hierarchy[user.role] >= role_hierarchy[required_role]
# Usage
@app.delete("/api/posts/{post_id}")
@require_role(Role.MODERATOR)
async def delete_post(post_id: int, current_user: User):
# Only moderators and admins can delete posts
post = get_post(post_id)
delete_post_from_db(post)
return {"success": True}Encryption and Data Protection
Encrypting Sensitive Data:
from cryptography.fernet import Fernet
import base64
# Generate key (do once, store securely)
encryption_key = Fernet.generate_key()
cipher = Fernet(encryption_key)
def encrypt_data(plaintext: str) -> str:
"""Encrypt sensitive data before storing"""
return cipher.encrypt(plaintext.encode()).decode()
def decrypt_data(ciphertext: str) -> str:
"""Decrypt data when needed"""
return cipher.decrypt(ciphertext.encode()).decode()
# Usage
user.social_security_number = encrypt_data("123-45-6789")
database.save(user)
# Later...
ssn = decrypt_data(user.social_security_number)HTTPS Configuration:
# FastAPI with HTTPS
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=443,
ssl_keyfile="/path/to/key.pem",
ssl_certfile="/path/to/cert.pem"
)
# Enforce HTTPS
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
app.add_middleware(HTTPSRedirectMiddleware)
# Security headers
from fastapi.middleware.trustedhost import TrustedHostMiddleware
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["example.com", "*.example.com"])
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return responseSecure Coding Practices
Principle of Least Privilege:
# Bad - using root database user
DATABASE_URL = "postgresql://root:password@localhost/myapp"
# Good - limited permissions user
DATABASE_URL = "postgresql://app_user:password@localhost/myapp"
# app_user can only SELECT, INSERT, UPDATE, DELETE on specific tables
# Cannot DROP tables, CREATE users, etc.Defense in Depth:
# Multiple layers of security
@app.post("/api/admin/users/{user_id}/delete")
async def delete_user(
user_id: int,
current_user: User = Depends(get_current_user),
csrf_token: str = Header(...)
):
# Layer 1: Authentication
if not current_user:
raise HTTPException(401, "Not authenticated")
# Layer 2: Authorization
if not current_user.is_admin:
raise HTTPException(403, "Admin required")
# Layer 3: CSRF protection
if not verify_csrf_token(csrf_token, current_user.id):
raise HTTPException(403, "Invalid CSRF token")
# Layer 4: Rate limiting
if not check_rate_limit(current_user.id, "delete_user"):
raise HTTPException(429, "Too many requests")
# Layer 5: Audit logging
log_admin_action(current_user.id, "delete_user", user_id)
# Finally perform action
delete_user_from_db(user_id)
return {"success": True}Fail Securely:
# Bad - fails open (grants access on error)
def check_permission(user_id: int, resource_id: int) -> bool:
try:
permissions = get_permissions(user_id)
return resource_id in permissions
except Exception:
return True # Grants access on error!
# Good - fails closed (denies access on error)
def check_permission(user_id: int, resource_id: int) -> bool:
try:
permissions = get_permissions(user_id)
return resource_id in permissions
except Exception as e:
logger.error(f"Permission check failed: {e}")
return False # Denies access on errorCommon Pitfalls
Pitfall 1: Rolling Your Own Crypto
Writing custom encryption/hashing algorithms almost always introduces vulnerabilities.
Best Practice: Use established libraries (bcrypt, Fernet, JWT) implemented by cryptography experts [2].
Pitfall 2: Security Through Obscurity
Relying on hiding implementation details rather than strong security controls.
Best Practice: Assume attackers know your system’s internals. Security should work even if implementation is public [4].
Pitfall 3: Trusting Client-Side Validation
Relying on JavaScript validation without server-side checks.
Best Practice: Always validate on the server. Client-side validation is for UX only, not security [3].
Pitfall 4: Ignoring Dependency Vulnerabilities
Using outdated libraries with known security issues.
Best Practice: Regularly update dependencies. Use automated scanning tools (Safety, Snyk, Dependabot) [1].
Summary
Security is fundamental to professional software development. The OWASP Top 10 identifies critical vulnerabilities: broken access control, cryptographic failures, injection, insecure design, misconfiguration, vulnerable components, authentication failures, software/data integrity failures, security logging failures, and server-side request forgery. Understanding and preventing these vulnerabilities is essential.
Input validation and sanitization prevent injection attacks and XSS. Validate all input, use parameterized queries, escape output, and sanitize rich text. Never trust user input.
Authentication and authorization require strong password hashing (bcrypt), secure session management, and proper access controls. Use JWT tokens, implement role-based access control, and enforce the principle of least privilege.
Encryption protects sensitive data at rest and in transit. Use strong encryption libraries, enforce HTTPS, and add security headers. Follow defense in depth with multiple security layers.
Secure coding practices include failing securely, logging security events, regular security audits, and keeping dependencies updated. Security isn’t optional—it’s professional responsibility.
Practice Quiz
Question 1: This code has a SQL injection vulnerability. How would an attacker exploit it, and how do you fix it?
def get_user(username: str):
query = f"SELECT * FROM users WHERE username = '{username}'"
return database.execute(query)Answer:
Exploit: Attacker sends username = "' OR '1'='1"
Resulting query: SELECT * FROM users WHERE username = '' OR '1'='1'
This returns ALL users because ‘1’=‘1’ is always true [3].
Fix:
# Use parameterized queries
def get_user(username: str):
query = "SELECT * FROM users WHERE username = ?"
return database.execute(query, (username,))
# Or use ORM
def get_user(username: str):
return db.query(User).filter(User.username == username).first()Question 2: Why is this password hashing insecure?
import hashlib
password_hash = hashlib.md5(password.encode()).hexdigest()Answer: Multiple problems:
- MD5 is cryptographically broken - fast to compute, vulnerable to rainbow tables
- No salt - identical passwords produce identical hashes
- No key stretching - attacker can try billions of passwords per second [2]
Fix:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password_hash = pwd_context.hash(password)Bcrypt includes salt automatically and is computationally expensive (resistant to brute force).
Question 3: This authorization check has a vulnerability. What is it?
@app.get("/api/users/{user_id}/profile")
def get_profile(user_id: int, current_user: User = Depends(get_current_user)):
user = get_user(user_id)
return {"email": user.email, "phone": user.phone}Answer: Broken Access Control - any authenticated user can view any other user’s profile. No check verifies current_user should access user_id’s data [3].
Fix:
@app.get("/api/users/{user_id}/profile")
def get_profile(user_id: int, current_user: User = Depends(get_current_user)):
if current_user.id != user_id and not current_user.is_admin:
raise HTTPException(403, "Access denied")
user = get_user(user_id)
return {"email": user.email, "phone": user.phone}References
[1] IBM Security. (2023). Cost of a Data Breach Report. URL: https://www.ibm.com/security/data-breach, Quote: “The average cost of a data breach in 2023 was $4.45 million. Compromised credentials were the most common initial attack vector, responsible for 19% of breaches.”
[2] Schneier, B. (2015). Applied Cryptography. Wiley. URL: https://www.schneier.com/books/applied-cryptography/, Quote: “Security is not a product, but a process. Rolling your own cryptography is almost always a mistake—use established, peer-reviewed algorithms implemented by experts.”
[3] OWASP Foundation. (2021). OWASP Top 10. URL: https://owasp.org/www-project-top-ten/, Quote: “The OWASP Top 10 is a standard awareness document representing a broad consensus about the most critical security risks to web applications. Understanding these vulnerabilities and implementing appropriate countermeasures is fundamental to secure development.”
[4] McGraw, G. (2006). Software Security: Building Security In. Addison-Wesley. URL: https://www.oreilly.com/library/view/software-security-building/0321356705/, Quote: “Security must be built in from the start, not bolted on later. Defensive coding practices, the principle of least privilege, and failing securely are essential. Assume attackers will probe every input and try every exploit—design accordingly.”