Security Guide¶
Comprehensive guide to securing HyperDjango applications: CSRF protection, CORS, security headers, rate limiting, input validation, SQL injection prevention, XSS prevention, and password security.
Table of Contents¶
- Security Middleware Stack
- CSRF Protection
- CORS Configuration
- Security Headers
- Rate Limiting
- Input Validation
- SQL Injection Prevention
- XSS Prevention
- Password Security
- Session Security
- Security Audit Logging
- Configuration Reference
- Migration Notes for Django Users
Security Middleware Stack¶
A production application should enable the full security middleware stack:
from hyperdjango import HyperApp
from hyperdjango.auth import SessionAuth
from hyperdjango.auth.db_sessions import DatabaseSessionStore
from hyperdjango.standalone_middleware import (
CORSMiddleware,
CSRFMiddleware,
RateLimitMiddleware,
SecurityHeadersMiddleware,
)
app = HyperApp("myapp", database="postgres://localhost/mydb")
# Order matters: outermost middleware runs first
# 1. Security headers on ALL responses
app.use(SecurityHeadersMiddleware(
hsts=True,
hsts_max_age=31536000,
csp="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'",
x_frame_options="DENY",
x_content_type_options="nosniff",
referrer_policy="strict-origin-when-cross-origin",
))
# 2. CORS for API endpoints
app.use(CORSMiddleware(
origins=["https://myapp.com", "https://admin.myapp.com"],
methods=["GET", "POST", "PUT", "DELETE"],
headers=["Content-Type", "Authorization", "X-CSRF-Token"],
allow_credentials=True,
max_age=86400,
))
# 3. Rate limiting
app.use(RateLimitMiddleware(max_requests=100, window=60))
# 4. CSRF protection (requires session middleware)
store = DatabaseSessionStore(app.db, max_age=86400)
app.use(SessionAuth(secret="your-secret-key", store=store))
app.use(CSRFMiddleware(secret="csrf-signing-secret"))
CSRF Protection¶
CSRF protection prevents cross-site request forgery by requiring a signed token on state-changing requests (POST, PUT, PATCH, DELETE).
How It Works¶
CSRFMiddlewaregenerates a signed CSRF token and stores it in the session- The token must be included in state-changing requests via header or form field
- The middleware validates the token before allowing the request to proceed
Configuration¶
from hyperdjango.standalone_middleware import CSRFMiddleware
app.use(CSRFMiddleware(
secret="csrf-signing-secret-256-bits",
cookie_name="csrf_token",
header_name="X-CSRF-Token",
safe_methods=("GET", "HEAD", "OPTIONS"),
))
Sending the CSRF Token¶
For API clients, read the CSRF token from the cookie and include it in the request header:
// JavaScript frontend
const csrfToken = document.cookie
.split("; ")
.find((row) => row.startsWith("csrf_token="))
?.split("=")[1];
fetch("/api/products", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ name: "Widget", price: 29.99 }),
credentials: "include",
});
For HTML forms:
<form method="POST" action="/products/create">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="text" name="name" />
<button type="submit">Create</button>
</form>
Exempt Endpoints¶
API endpoints that use API key authentication (not cookies) typically do not need CSRF protection. The middleware skips requests that do not have a session cookie.
CORS Configuration¶
Control which origins can make cross-origin requests:
from hyperdjango.standalone_middleware import CORSMiddleware
# Strict: specific origins only
app.use(CORSMiddleware(
origins=["https://myapp.com", "https://admin.myapp.com"],
methods=["GET", "POST", "PUT", "DELETE"],
headers=["Content-Type", "Authorization", "X-CSRF-Token"],
allow_credentials=True,
max_age=86400, # Preflight cache time in seconds
))
# Development: allow all origins (never use in production)
app.use(CORSMiddleware(origins=["*"]))
CORS Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
origins |
list[str] |
["*"] |
Allowed origin URLs |
methods |
list[str] |
["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] |
Allowed HTTP methods |
headers |
list[str] |
["*"] |
Allowed request headers |
allow_credentials |
bool |
False |
Allow cookies/auth headers |
max_age |
int |
86400 |
Preflight cache duration in seconds |
Common Mistakes¶
- Setting
origins=["*"]withallow_credentials=True-- browsers reject this combination. - Forgetting to include
X-CSRF-Tokeninheaderswhen using CSRF protection. - Not including
Content-Typeinheadersfor JSON APIs.
Security Headers¶
The SecurityHeadersMiddleware adds protective HTTP headers to every response:
from hyperdjango.standalone_middleware import SecurityHeadersMiddleware
app.use(SecurityHeadersMiddleware(
hsts=True,
hsts_max_age=31536000, # 1 year
hsts_include_subdomains=True,
hsts_preload=True,
csp="default-src 'self'; script-src 'self'; img-src 'self' data:",
x_frame_options="DENY",
x_content_type_options="nosniff",
referrer_policy="strict-origin-when-cross-origin",
permissions_policy="camera=(), microphone=(), geolocation=()",
))
Headers Set¶
| Header | Value | Purpose |
|---|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Force HTTPS |
Content-Security-Policy |
default-src 'self' |
Prevent XSS, injection |
X-Frame-Options |
DENY |
Prevent clickjacking |
X-Content-Type-Options |
nosniff |
Prevent MIME sniffing |
Referrer-Policy |
strict-origin-when-cross-origin |
Control referrer leakage |
Permissions-Policy |
camera=(), microphone=() |
Disable browser features |
Content Security Policy Examples¶
# Strict (no inline scripts, no external resources)
csp = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'"
# Allow inline styles (common for CSS frameworks)
csp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
# Allow CDN resources
csp = "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net"
# Allow images from anywhere
csp = "default-src 'self'; img-src *"
# Report violations (useful during rollout)
csp = "default-src 'self'; report-uri /api/csp-report"
Per-View Overrides¶
from hyperdjango.shortcuts import xframe_options_deny
@app.get("/embed")
@xframe_options_deny
async def no_embed(request):
"""Override X-Frame-Options for this specific view."""
return Response.html("<h1>Cannot be embedded</h1>")
Rate Limiting¶
In-Memory Rate Limiting (Single Server)¶
from hyperdjango.standalone_middleware import RateLimitMiddleware
# 100 requests per minute per IP
app.use(RateLimitMiddleware(max_requests=100, window=60))
Database-Backed Rate Limiting (Multi-Server)¶
from hyperdjango.ratelimit import DatabaseRateLimitBackend, RateLimitMiddleware
backend = DatabaseRateLimitBackend(app.db)
await backend.ensure_table() # Creates PostgreSQL UNLOGGED table
app.use(RateLimitMiddleware(
max_requests=100,
window=60,
backend=backend,
))
Hierarchical Rate Limits¶
Apply different limits at different scopes:
from hyperdjango.ratelimit import ip_key, org_key, user_key
# Layer 1: 10 requests/second per IP (burst protection)
app.use(RateLimitMiddleware(max_requests=10, window=1, key_func=ip_key))
# Layer 2: 100 requests/minute per user
app.use(RateLimitMiddleware(max_requests=100, window=60, key_func=user_key))
# Layer 3: 5000 requests/hour per organization
app.use(RateLimitMiddleware(max_requests=5000, window=3600, key_func=org_key))
Tiered Rate Limiting (Per-Plan Limits)¶
from hyperdjango.ratelimit import TieredRateLimitMiddleware
tiers = {
"free": {"max_requests": 100, "window": 60},
"pro": {"max_requests": 1000, "window": 60},
"enterprise": {"max_requests": 10000, "window": 60},
}
app.use(TieredRateLimitMiddleware(
tiers=tiers,
default_tier="free",
db=app.db,
))
Rule-Based Rate Limiting¶
Different limits for different endpoints:
from hyperdjango.ratelimit import RuleBasedRateLimitMiddleware
rules = [
{"path": "/api/auth/login", "method": "POST", "max_requests": 5, "window": 60},
{"path": "/api/auth/register", "method": "POST", "max_requests": 3, "window": 300},
{"path": "/api/search", "method": "GET", "max_requests": 30, "window": 60},
{"path": "/api/*", "method": "*", "max_requests": 100, "window": 60},
]
app.use(RuleBasedRateLimitMiddleware(rules=rules, db=app.db))
Rate Limit Response¶
When a client exceeds the limit, a 429 response is returned:
The Retry-After header indicates when the client can retry.
Input Validation¶
Model-Level Validation (Native Zig SIMD)¶
Model fields with validation constraints are checked at the native level using SIMD-accelerated validation:
from hyperdjango.models import Field, Model
class UserRegistration(Model):
class Meta:
table = "users"
id: int = Field(primary_key=True, auto=True)
username: str = Field(min_length=3, max_length=30, regex=r"^[a-zA-Z0-9_]+$")
email: str = Field() # Email validation via native SIMD (77ns per email)
age: int = Field(ge=13, le=150)
password: str = Field(min_length=8)
Validation performance:
- 1.6M model validations/second (full model with all fields)
- 6.7M individual field validations/second
- 51.5M integer range checks/second (SIMD 4-wide)
- 77ns per email validation (SIMD)
Form Validation¶
from hyperdjango.forms import CharField, Form, IntegerField
class ContactForm(Form):
name = CharField(max_length=100, required=True)
email = CharField(max_length=200, required=True)
message = CharField(widget="textarea", required=True)
age = IntegerField(min_value=0, max_value=150, required=False)
def clean(self, data):
"""Cross-field validation."""
errors = {}
if data.get("message") and len(data["message"]) < 10:
errors["message"] = ["Message must be at least 10 characters"]
return errors
form = ContactForm(data=request.json)
if form.is_valid():
cleaned = form.cleaned_data
else:
errors = form.errors # {"name": ["This field is required"], ...}
Request Body Size Limit¶
Requests exceeding the body size limit are rejected before the body is read.
SQL Injection Prevention¶
All database queries use parameterized placeholders. User input is NEVER interpolated into SQL strings.
Parameterized Queries (Safe)¶
# ORM queries are always parameterized
users = await User.objects.filter(username=user_input).all()
# Generates: SELECT * FROM users WHERE username = $1
# With params: [user_input]
# Raw queries use $N placeholders
rows = await db.query(
"SELECT * FROM products WHERE name ILIKE $1 AND price < $2",
f"%{search_term}%",
max_price,
)
# where_raw uses {idx} placeholders (converted to $N internally)
results = await Product.objects.where_raw(
"(name ILIKE {idx} OR description ILIKE {idx})",
f"%{search_term}%",
).all()
What NOT to Do¶
# DANGEROUS: string interpolation (NEVER do this)
# rows = await db.query(f"SELECT * FROM users WHERE name = '{name}'")
# DANGEROUS: format strings in SQL
# rows = await db.query("SELECT * FROM users WHERE name = '%s'" % name)
The pg.zig driver sends parameters separately from the SQL statement using the PostgreSQL binary protocol. Parameters are never parsed as SQL.
XSS Prevention¶
Template Auto-Escaping¶
The native Zig template engine auto-escapes all output by default:
{# User input is automatically HTML-escaped #}
<p>{{ user.name }}</p>
{# If user.name = '
<script>
alert("xss");
</script>
' #} {# Output:
<p><script>alert("xss")</script></p>
#}
To disable escaping for trusted content:
{# Only use for content you trust completely #} {% autoescape false %} {{
trusted_html_content }} {% endautoescape %} {# Or per-variable #} {{
trusted_content | safe }}
JSON Response Escaping¶
Response.json() serializes data via the native SIMD JSON serializer, which properly escapes all string values:
# Special characters are escaped in JSON output
return Response.json({"message": user_input})
# Produces valid JSON with escaped quotes, backslashes, etc.
SIMD HTML Escaping¶
The native Zig html_escape function processes strings 1.7-3.4x faster than Python:
from hyperdjango.native._strings import html_escape
safe_html = html_escape(user_input)
# Escapes: & < > " '
Password Security¶
argon2id Hashing¶
All passwords are hashed with argon2id, the recommended algorithm for password hashing (RFC 9106). It is:
- Memory-hard: resistant to GPU/ASIC attacks
- Time-hard: configurable iteration count
- Side-channel resistant: the "id" variant
from hyperdjango.auth import hash_password, needs_rehash, verify_password
# Hash (includes random salt)
hashed = hash_password("user-password")
# Verify (constant-time comparison)
is_valid = verify_password("user-password", hashed)
# Automatic rehashing when parameters are upgraded
if needs_rehash(hashed):
new_hash = hash_password("user-password")
Password Strength Validation¶
from hyperdjango.auth.password_validation import validate_password
errors = validate_password("short")
# ["Password must be at least 8 characters"]
errors = validate_password("12345678")
# ["Password is entirely numeric"]
errors = validate_password("password123")
# ["Password is too common"]
errors = validate_password("Xk9#mP2$vL7!", user=user)
# [] (valid)
Built-in validators:
- Minimum length (default: 8 characters)
- Maximum length
- Numeric-only check
- Common password list (20,000+ passwords)
- User attribute similarity (username, email)
Session Security¶
Secure Session Configuration¶
from hyperdjango.auth import SessionAuth
from hyperdjango.auth.db_sessions import DatabaseSessionStore
store = DatabaseSessionStore(app.db, max_age=86400) # 24 hours
await store.ensure_table()
app.use(SessionAuth(
secret="256-bit-secret-key",
store=store,
cookie_name="session",
cookie_httponly=True, # Not accessible via JavaScript
cookie_secure=True, # HTTPS only (set True in production)
cookie_samesite="Lax", # Prevents CSRF on most cross-site requests
))
Session Invalidation¶
Sessions are automatically invalidated when a user changes their password (via the session auth hash mechanism):
# When password changes, all existing sessions for that user become invalid
user.set_password("new-password")
await user.save()
# All other browser sessions will require re-login on their next request
Manual Session Invalidation¶
Security Audit Logging¶
Track security-relevant events:
from hyperdjango.security import SecurityEvent, SecurityLog
security_log = SecurityLog(app.db)
await security_log.ensure_table()
# Log events
await security_log.log(SecurityEvent(
event_type="login_success",
user_id=user.id,
ip_address=request.client_ip,
details={"username": user.username},
))
await security_log.log(SecurityEvent(
event_type="login_failed",
ip_address=request.client_ip,
details={"username": attempted_username},
))
# Query events
events = await security_log.get_events(
event_type="login_failed",
ip_address="192.168.1.100",
since=datetime.now(UTC) - timedelta(hours=1),
)
# User activity
activity = await security_log.get_user_events(user_id=user.id, limit=50)
# Cleanup old events
await security_log.cleanup(older_than=timedelta(days=90))
Event Types¶
| Event Type | When Logged |
|---|---|
login_success |
Successful authentication |
login_failed |
Failed authentication attempt |
logout |
User logout |
password_changed |
Password update |
permission_denied |
Authorization failure |
rate_limited |
Rate limit exceeded |
csrf_failed |
CSRF token validation failure |
Configuration Reference¶
SecurityHeadersMiddleware¶
| Parameter | Type | Default | Description |
|---|---|---|---|
hsts |
bool |
False |
Enable HSTS header |
hsts_max_age |
int |
31536000 |
HSTS max-age in seconds |
hsts_include_subdomains |
bool |
True |
Include subdomains in HSTS |
hsts_preload |
bool |
False |
HSTS preload flag |
csp |
str |
None |
Content-Security-Policy value |
x_frame_options |
str |
"DENY" |
X-Frame-Options value |
x_content_type_options |
str |
"nosniff" |
X-Content-Type-Options value |
referrer_policy |
str |
"strict-origin-when-cross-origin" |
Referrer-Policy value |
permissions_policy |
str |
None |
Permissions-Policy value |
CSRFMiddleware¶
| Parameter | Type | Default | Description |
|---|---|---|---|
secret |
str |
required | Token signing key |
cookie_name |
str |
"csrf_token" |
CSRF cookie name |
header_name |
str |
"X-CSRF-Token" |
CSRF header name |
safe_methods |
tuple[str] |
("GET", "HEAD", "OPTIONS") |
Methods exempt from CSRF |
RateLimitMiddleware¶
| Parameter | Type | Default | Description |
|---|---|---|---|
max_requests |
int |
100 |
Max requests per window |
window |
int |
60 |
Window size in seconds |
key_func |
callable |
ip_key |
Function to extract rate limit key |
backend |
backend | InMemoryBackend |
Storage backend |
Migration Notes for Django Users¶
Key Differences¶
| Django | HyperDjango |
|---|---|
{% csrf_token %} template tag |
X-CSRF-Token header or hidden field |
django-cors-headers package |
Built-in CORSMiddleware |
SECURE_HSTS_SECONDS setting |
SecurityHeadersMiddleware(hsts_max_age=...) |
SECURE_CONTENT_TYPE_NOSNIFF setting |
SecurityHeadersMiddleware(x_content_type_options=...) |
X_FRAME_OPTIONS setting |
SecurityHeadersMiddleware(x_frame_options=...) |
django-ratelimit package |
Built-in RateLimitMiddleware |
| PostgreSQL UNLOGGED table for rate limiting | Built-in |
django.contrib.auth.hashers |
hyperdjango.auth.passwords (argon2id only) |
| Multiple password hashers | Single hasher (argon2id) |
settings.py security config |
Middleware constructor parameters |
What Stayed the Same¶
- CSRF protection concept (token verification on state-changing requests)
- Session-based authentication (server-side sessions, signed cookies)
- Parameterized queries for SQL injection prevention
- Auto-escaping in templates for XSS prevention
- Password hashing with automatic upgrade support