Skip to content

Security: Signi-AI/backend-engineering-standards

Security

security.md

Security Standards

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.


Table of Contents

  1. Environment Configuration
  2. Password Security
  3. JWT Security
  4. Input Validation
  5. SQL Injection
  6. CORS
  7. Rate Limiting and Brute-Force Protection
  8. File Upload Security
  9. Logging Security
  10. Dependency Security
  11. Docker and Production Configuration
  12. Security Checklist

Environment Configuration

The Golden Rule

Never commit secrets to version control. Ever.

Secrets include: passwords, API keys, JWT secret keys, database URLs, private keys, webhook secrets.

.env File

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

.gitignore

# Secrets
.env
.env.local
.env.production

# Never commit these
*.pem
*.key
*.p12
secrets/

# Other sensitive/generated files
__pycache__/
.pytest_cache/
*.pyc
dist/
uploads/

Generating a Secure Secret Key

# 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))"

Pydantic Settings Validation

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

Password Security

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)

JWT Security

  • Use a strong secret key (minimum 32 bytes of entropy)
  • Use HS256 as the default algorithm (or RS256 for distributed systems)
  • Set short expiry on access tokens (15-60 minutes)
  • Set appropriate expiry on refresh tokens (7-30 days)
  • Always validate the exp claim (python-jose does this automatically)
  • Validate type claim to prevent refresh tokens being used as access tokens
  • Never store JWTs in localStorage if XSS is a concern

Input Validation

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 v

Validation rules:

  • Validate length (min and max) on all string inputs
  • Use EmailStr for 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

SQL Injection

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

CORS

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 Limiting and Brute-Force Protection

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/register
  • POST /auth/forgot-password
  • Any endpoint that sends emails or SMS

File Upload Security

See Architecture for general file handling. Security-specific rules:

Validate File Type

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 content

Generate Safe Filenames

Never 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

Never Allow Executable Uploads

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.

Object Storage in Production

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.


Logging Security

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)

Dependency Security

Regularly audit and update Python dependencies:

# Check for known vulnerabilities
pip install safety
safety check

# Or use pip-audit
pip install pip-audit
pip-audit

In GitHub Actions, add a security audit step:

- name: Security audit
  run: |
    pip install safety
    safety check -r requirements.txt

Docker and Production Configuration

Never hardcode secrets in Dockerfile or docker-compose.yml

Bad:

# docker-compose.yml
environment:
  - DATABASE_URL=postgresql://admin:password123@db:5432/prod
  - SECRET_KEY=my-secret-key

Good — reference from .env file:

# docker-compose.yml
services:
  app:
    env_file:
      - .env

Or for production, use Docker secrets or your platform's secret management (AWS Secrets Manager, etc.).

Disable Debug Mode in Production

# 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,
)

Principle of Least Privilege

  • 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

Security Checklist

Use this checklist before every production deployment.

Secrets and Configuration

  • .env is in .gitignore and not committed
  • .env.example exists with no real values
  • SECRET_KEY is at least 32 bytes of entropy
  • Database credentials are not hardcoded
  • DEBUG=False in production
  • Swagger/ReDoc is disabled in production

Authentication and Authorization

  • Passwords are hashed with bcrypt
  • Access tokens expire in 15-60 minutes
  • All protected endpoints use get_current_user dependency
  • Resource ownership is verified (not just authentication)
  • Failed login attempts are logged (without the password)
  • Password reset tokens are short-lived and single-use

Input Validation

  • All request bodies use Pydantic schemas
  • String inputs have length limits
  • File uploads validate MIME type and size
  • No string-formatted SQL queries

API Design

  • 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

File Uploads

  • 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

Logging

  • No passwords in logs
  • No full tokens in logs
  • Structured logging enabled
  • Failed auth attempts logged

Dependencies

  • safety check or pip-audit passes
  • Dependencies pinned in requirements.txt

There aren't any published security advisories