Jamie Software Lab
Home / Engineering / Security Engineering
Security OWASP Auth Hardening

Security Engineering

A deep dive into the defensive engineering practices I apply across all projects. OWASP Top 10 mitigations, input validation, authentication, rate limiting, secrets management, and secure code patterns.

OWASP Top 10 Mitigations

I treat the OWASP Top 10 as a checklist for every project. Below are the specific mitigations I apply.

A01
Broken Access Control

Every protected route checks JWT claims. Role-based access (USER, ADMIN, MODERATOR) is enforced at the middleware level. Direct object references use CUIDs (not sequential IDs) and ownership validation.

A02
Cryptographic Failures

Passwords hashed with PBKDF2-SHA256 (600,000 iterations). JWT secrets loaded from environment variables. TLS enforced everywhere. No sensitive data in logs or error responses.

A03
Injection

All SQL uses parameterised queries (Prisma / SQLite ? placeholders). User input is validated with Pydantic models. HTML output is escaped by default in Jinja2 templates.

A05
Security Misconfiguration

Nginx adds security headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy. Debug mode disabled in production. Error pages don't leak stack traces.

A07
Cross-Site Scripting (XSS)

Output encoding by default. User content is escaped before rendering. No innerHTML usage with untrusted data. Content Security Policy headers restrict inline scripts.

A09
Logging & Monitoring Failures

All authentication events are logged (login success, failure, token refresh). Failed login attempts tracked per IP. Logs are structured JSON for easy search and alerting.

SQL Injection Prevention

Every database query uses parameterised statements. No string concatenation for SQL, ever. Here's the difference:

✗ WRONG
String concatenation (vulnerable)
# NEVER DO THIS : vulnerable to injection
query = f"SELECT * FROM users WHERE email = '{email}'"
cursor.execute(query)  # Attacker: ' OR 1=1 --
✓ CORRECT
Parameterised query (safe)
# Always use parameterised queries
cursor.execute(
    "SELECT * FROM users WHERE email = ?",
    (email,)
)  # Input is escaped by the driver

XSS Protection

Cross-site scripting is prevented at multiple layers: input validation, output encoding, and Content Security Policy.

python : Output escaping
from markupsafe import escape

def render_comment(user_input: str) -> str:
    """Escape user input before rendering in HTML."""
    # escape() converts <script> → &lt;script&gt;
    return f"<p class='comment'>{escape(user_input)}</p>"
javascript : Safe DOM manipulation
// NEVER: element.innerHTML = untrustedData;
// ALWAYS: use textContent for user-generated content
const el = document.createElement("span");
el.textContent = userInput;  // Auto-escaped by the browser
container.appendChild(el);
🛡️
Defence in Depth
  • Input validation : reject invalid data at the boundary (Pydantic, regex)
  • Output encoding : escape HTML entities before rendering (Jinja2 auto-escape, textContent)
  • CSP headers : Content-Security-Policy restricts script sources
  • HTTPOnly cookies : tokens in cookies that JavaScript cannot read

CSRF Protection

For API services using JWT in the Authorization header, CSRF isn't a risk (the token isn't sent automatically by the browser). For cookie-based auth, I use the double-submit cookie pattern.

🍪
Cookie-Based Auth

Set a random CSRF token in a non-HTTPOnly cookie. Every state-changing request must include this token in a custom header. The server compares cookie value with header value : a cross-origin page can't read the cookie to forge the header.

🔑
Bearer Token Auth

JWT sent in the Authorization: Bearer header is not auto-attached by the browser, so CSRF attacks can't work. The attacker would need to steal the token itself : which is mitigated by HTTPOnly storage and short TTLs.

Input Validation Patterns

All user input is validated at the API boundary before reaching business logic. Strict typing, length limits, regex patterns, and domain-specific constraints.

python : Validation patterns
from pydantic import BaseModel, Field, field_validator
import re

class CreateLinkRequest(BaseModel):
    url: str = Field(
        ...,
        min_length=10,
        max_length=2048,
        pattern=r"^https?://",
    )
    custom_code: str | None = Field(
        None,
        min_length=3,
        max_length=20,
        pattern=r"^[a-zA-Z0-9_-]+$",  # Safe chars only
    )

    @field_validator("url")
    def no_private_urls(cls, v):
        """Reject URLs pointing to internal networks."""
        from urllib.parse import urlparse
        host = urlparse(v).hostname or ""
        if host in ("localhost", "127.0.0.1") or host.startswith("192.168."):
            raise ValueError("Private URLs not allowed")
        return v

Authentication Flow

Complete login → token → refresh → logout flow. See also: API Engineering → Authentication.

Full Authentication Flow
Login Form
email + password
Server
verify + hash
JWT Pair
access + refresh
Client
Bearer header
01
Login

Client sends email + password over TLS. Server verifies with constant-time comparison (hmac.compare_digest). Issues JWT pair on success.

02
Access

Access token (15 min TTL) in Authorization header. Contains user ID and role in claims. Verified by middleware on every protected request.

03
Refresh

When access token expires, client sends refresh token (7 day TTL). Server issues a new access token and rotates the refresh token. Old refresh token is invalidated.

python : Secure password hashing
import hashlib, os

def hash_password(password: str) -> str:
    """PBKDF2-SHA256 with per-user salt. Returns salt:hash."""
    salt = os.urandom(32)
    key = hashlib.pbkdf2_hmac(
        "sha256",
        password.encode(),
        salt,
        iterations=600_000,
    )
    return salt.hex() + ":" + key.hex()

def verify_password(password: str, stored: str) -> bool:
    """Constant-time comparison to prevent timing attacks."""
    salt_hex, key_hex = stored.split(":")
    salt = bytes.fromhex(salt_hex)
    key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000)
    import hmac
    return hmac.compare_digest(key.hex(), key_hex)

Rate Limiting

Per-IP rate limiting on all API endpoints prevents abuse. Sensitive endpoints (login, registration) have stricter limits.

Rate Limiting Architecture
Request
client IP
Rate Limiter
sliding window
Allow
process request
Endpoint Limit Window Reason
POST /login 5 60s Brute force prevention
POST /register 3 60s Account spam prevention
POST /shorten 30 60s API abuse prevention
GET /api/* 60 60s General rate protection
python : Sliding window rate limiter
import time
from collections import defaultdict

class RateLimiter:
    def __init__(self, max_requests: int, window: int):
        self.max = max_requests
        self.window = window
        self.hits = defaultdict(list)

    def is_allowed(self, key: str) -> bool:
        now = time.monotonic()
        # Remove expired entries
        self.hits[key] = [t for t in self.hits[key] if now - t < self.window]
        if len(self.hits[key]) >= self.max:
            return False
        self.hits[key].append(now)
        return True

Secrets Management

No secrets in code, no secrets in frontend, no secrets in logs. Everything sensitive is loaded from environment variables.

🔐
Environment Variables

JWT_SECRET, DATABASE_URL, API keys : all loaded from os.environ. Fail loudly at startup if missing.

🚫
.gitignore

.env, *.pem, *.key : all excluded from version control. pre-commit hook scans for accidental secret commits.

📝
Log Sanitization

Logs never contain passwords, tokens, or keys. Request logging strips the Authorization header before writing.

python : Startup validation
import os, sys

REQUIRED_ENV = ["JWT_SECRET", "DATABASE_URL"]

def validate_config():
    """Fail fast if required secrets are missing."""
    missing = [k for k in REQUIRED_ENV if not os.environ.get(k)]
    if missing:
        print(f"FATAL: Missing environment variables: {missing}", file=sys.stderr)
        sys.exit(1)

validate_config()  # Called at import time