Related: Authentication | Error Handling | Database Guidelines
Security is a first-class engineering requirement. It is not an afterthought.
This document covers all security requirements for backend applications built at the club.
- Environment Configuration
- Password Security
- JWT Security
- Input Validation
- SQL Injection
- CORS
- Rate Limiting and Brute-Force Protection
- File Upload Security
- Logging Security
- Dependency Security
- Docker and Production Configuration
- Security Checklist
Never commit secrets to version control. Ever.
Secrets include: passwords, API keys, JWT secret keys, database URLs, private keys, webhook secrets.
# .env (NEVER COMMIT THIS)
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
SECRET_KEY=your-very-long-random-secret-key-generated-with-openssl
REDIS_URL=redis://localhost:6379/0
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
ENVIRONMENT=development# .env.example (COMMIT THIS - shows required variables, no real values)
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
SECRET_KEY=generate-with-openssl-rand-hex-32
REDIS_URL=redis://localhost:6379/0
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
ENVIRONMENT=development# Secrets
.env
.env.local
.env.production
# Never commit these
*.pem
*.key
*.p12
secrets/
# Other sensitive/generated files
__pycache__/
.pytest_cache/
*.pyc
dist/
uploads/# Generate a 32-byte (256-bit) hex secret key
openssl rand -hex 32
# Or with Python
python -c "import secrets; print(secrets.token_hex(32))"Fail fast at startup if required secrets are missing:
class Settings(BaseSettings):
DATABASE_URL: str # Required - will raise error if missing
SECRET_KEY: str # Required - will raise error if missing
ENVIRONMENT: str = 'development'
class Config:
env_file = '.env'
# This raises a validation error at startup if DATABASE_URL or SECRET_KEY are missing
settings = Settings()Covered fully in Authentication. Summary:
- Use
passlib[bcrypt]— never MD5, SHA1, or SHA256 alone - Use
pwd_context.verify()for timing-safe comparison - Enforce minimum password policy in Pydantic schema
- Log failed login attempts (not the password itself)
- Use a strong secret key (minimum 32 bytes of entropy)
- Use
HS256as the default algorithm (orRS256for distributed systems) - Set short expiry on access tokens (15-60 minutes)
- Set appropriate expiry on refresh tokens (7-30 days)
- Always validate the
expclaim (python-jose does this automatically) - Validate
typeclaim to prevent refresh tokens being used as access tokens - Never store JWTs in
localStorageif XSS is a concern
Never trust client input. Validate everything at the system boundary.
Bad — no validation:
@router.post('/posts')
def create_post(title: str, content: str):
# title could be 10MB of text
# content could contain injection payloads
return PostService.create(title, content)Good — Pydantic schema with explicit constraints:
from pydantic import BaseModel, field_validator
class PostCreate(BaseModel):
title: str
content: str
is_published: bool = False
@field_validator('title')
@classmethod
def validate_title(cls, v: str) -> str:
v = v.strip()
if len(v) < 5:
raise ValueError('Title must be at least 5 characters')
if len(v) > 500:
raise ValueError('Title must not exceed 500 characters')
return v
@field_validator('content')
@classmethod
def validate_content(cls, v: str) -> str:
if len(v) > 50000:
raise ValueError('Content must not exceed 50000 characters')
return vValidation rules:
- Validate length (min and max) on all string inputs
- Use
EmailStrfor email fields - Use typed parameters (int, UUID) for path parameters — FastAPI coerces and validates
- Validate file types and sizes for uploads
- Whitelist allowed values using Enum types
SQLAlchemy with parameterized queries prevents SQL injection automatically. Do not construct raw SQL with string concatenation.
Bad — raw string SQL (vulnerable):
# VULNERABLE: user_input can contain SQL injection
query = f"SELECT * FROM users WHERE email = '{user_input}'"
result = db.execute(query)
# Also bad with SQLAlchemy text() without parameters
result = db.execute(text(f"SELECT * FROM users WHERE email = '{user_input}'"))Good — parameterized queries:
from sqlalchemy import text
# Good: parameterized with text()
result = db.execute(text('SELECT * FROM users WHERE email = :email'), {'email': user_input})
# Even better: use SQLAlchemy ORM (parameterization is automatic)
user = db.query(User).filter(User.email == user_input).first()Configure CORS to allow only your frontend domains. Do not use * in production.
Bad — open CORS:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=['*'], # Any website can call your API
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)Good — restricted CORS from config:
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS, # ['https://myapp.com']
allow_credentials=True,
allow_methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allow_headers=['Authorization', 'Content-Type'],
)# .env.example
ALLOWED_ORIGINS=["https://myapp.com","https://www.myapp.com"]Rate limit authentication endpoints to prevent brute-force attacks.
Using slowapi with Redis (recommended):
from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(
status_code=429,
content={'detail': 'Too many requests. Try again later.'},
)
@router.post('/auth/login')
@limiter.limit('5/minute') # Max 5 login attempts per minute per IP
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
...Also apply to:
POST /auth/registerPOST /auth/forgot-password- Any endpoint that sends emails or SMS
See Architecture for general file handling. Security-specific rules:
Never trust the extension or Content-Type header alone. Use python-magic for MIME sniffing:
import magic
from fastapi import HTTPException, UploadFile
ALLOWED_MIME_TYPES = {'image/jpeg', 'image/png', 'image/webp', 'image/gif'}
MAX_FILE_SIZE_MB = 5
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
async def validate_image_upload(file: UploadFile) -> bytes:
content = await file.read()
if len(content) > MAX_FILE_SIZE_BYTES:
raise HTTPException(status_code=413, detail=f'File exceeds {MAX_FILE_SIZE_MB}MB limit')
# Check actual MIME type from file bytes (not header)
mime_type = magic.from_buffer(content, mime=True)
if mime_type not in ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=415,
detail=f'Unsupported file type. Allowed: {ALLOWED_MIME_TYPES}',
)
return contentNever use the original filename from the client. Generate a UUID-based filename:
import uuid
from pathlib import Path
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
def generate_safe_filename(original_filename: str) -> str:
suffix = Path(original_filename).suffix.lower()
if suffix not in ALLOWED_EXTENSIONS:
raise ValueError('Invalid file extension')
return f'{uuid.uuid4()}{suffix}'Why?
- Prevents path traversal attacks (
../../etc/passwd) - Prevents overwriting existing files
- Prevents serving executable files with disguised names
Blocked extensions must include: .py, .sh, .exe, .js, .php, .rb, .bat, .ps1
Use an explicit allowlist of safe extensions, not a blocklist of dangerous ones.
Store files in cloud object storage (S3, GCS, MinIO) rather than local filesystem:
import boto3
from app.core.config import settings
s3 = boto3.client(
's3',
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
region_name=settings.AWS_REGION,
)
def upload_file(content: bytes, filename: str, content_type: str) -> str:
s3.put_object(
Bucket=settings.S3_BUCKET,
Key=f'uploads/{filename}',
Body=content,
ContentType=content_type,
# ServerSideEncryption='AES256', # Enable if required
)
return filename # Store filename in DB, not URL
def get_presigned_url(filename: str, expiry_seconds: int = 3600) -> str:
return s3.generate_presigned_url(
'get_object',
Params={'Bucket': settings.S3_BUCKET, 'Key': f'uploads/{filename}'},
ExpiresIn=expiry_seconds,
)For private files, generate presigned URLs rather than making objects public.
Never log:
- Passwords (even hashed)
- Full JWT tokens
- Credit card numbers
- Private keys
- Full request bodies that may contain sensitive data
- Personal data beyond what is necessary
Log:
- User ID (not email) for audit events
- IP address for security events
- Timestamps
- Action type
- Success/failure status
# Good: log user ID, not email or password
logger.info('User %s logged in from %s', str(user.id), request.client.host)
# Bad: logs the password
logger.debug('Login attempt: email=%s password=%s', email, password)
# Bad: logs full token (can be replayed)
logger.debug('Token issued: %s', token)Regularly audit and update Python dependencies:
# Check for known vulnerabilities
pip install safety
safety check
# Or use pip-audit
pip install pip-audit
pip-auditIn GitHub Actions, add a security audit step:
- name: Security audit
run: |
pip install safety
safety check -r requirements.txtBad:
# docker-compose.yml
environment:
- DATABASE_URL=postgresql://admin:password123@db:5432/prod
- SECRET_KEY=my-secret-keyGood — reference from .env file:
# docker-compose.yml
services:
app:
env_file:
- .envOr for production, use Docker secrets or your platform's secret management (AWS Secrets Manager, etc.).
# app/core/config.py
class Settings(BaseSettings):
ENVIRONMENT: str = 'development'
DEBUG: bool = False
# main.py
app = FastAPI(
debug=settings.DEBUG, # False in production
docs_url='/docs' if settings.ENVIRONMENT != 'production' else None,
redoc_url='/redoc' if settings.ENVIRONMENT != 'production' else None,
)- Database user should only have SELECT, INSERT, UPDATE, DELETE (not DROP, CREATE)
- S3 bucket policy should restrict access to specific prefixes
- Service accounts should only have permissions they need
Use this checklist before every production deployment.
-
.envis in.gitignoreand not committed -
.env.exampleexists with no real values -
SECRET_KEYis at least 32 bytes of entropy - Database credentials are not hardcoded
-
DEBUG=Falsein production - Swagger/ReDoc is disabled in production
- Passwords are hashed with bcrypt
- Access tokens expire in 15-60 minutes
- All protected endpoints use
get_current_userdependency - Resource ownership is verified (not just authentication)
- Failed login attempts are logged (without the password)
- Password reset tokens are short-lived and single-use
- All request bodies use Pydantic schemas
- String inputs have length limits
- File uploads validate MIME type and size
- No string-formatted SQL queries
- CORS is restricted to known origins
- Rate limiting is applied to auth endpoints
- Response schemas do not include internal fields
- UUIDs used for public-facing IDs
- MIME type validated from file bytes (not header)
- Filename is server-generated (UUID-based)
- Executable extensions are blocked
- File size is capped
- Files stored in object storage (not filesystem in production)
- Private files use signed URLs
- No passwords in logs
- No full tokens in logs
- Structured logging enabled
- Failed auth attempts logged
-
safety checkorpip-auditpasses - Dependencies pinned in
requirements.txt