Security Settings
Production requirements: Strong SECRET_KEY (50+ chars), reverse proxy with SSL, specific security_domains, environment variables for secrets.
Environment Modes
| Setting | Development | Production |
|---|---|---|
CORS | All origins allowed | Strict whitelist from security_domains |
ALLOWED_HOSTS | ['*'] | Generated from security_domains |
CSRF | Dev ports (3000, 5173, 8080, etc.) | Trusted origins from domains |
DEBUG | Enabled | Forced disabled |
SSL | Disabled | Reverse proxy assumed |
security_domains Field
The security_domains field is the foundation of security configuration:
from django_cfg import DjangoConfig
class MyConfig(DjangoConfig):
secret_key: str = "your-secret-key"
debug: bool = False
# Production domains (flexible format - Django-CFG normalizes automatically)
security_domains: list = [
"myapp.com", # ✅ No protocol
"https://api.myapp.com", # ✅ With protocol
"admin.myapp.com:8443", # ✅ With port
]
# Development: security_domains optional (CORS fully open by default)
class DevConfig(DjangoConfig):
debug: bool = True
# security_domains not needed - auto-configured for developmentAuto-Generated Settings
From security_domains = ["myapp.com", "api.myapp.com"]:
ALLOWED_HOSTS = ['myapp.com', 'api.myapp.com'] # + Docker IPs if detected
CORS_ALLOWED_ORIGINS = ['https://myapp.com', 'https://api.myapp.com']
CSRF_TRUSTED_ORIGINS = ['https://myapp.com', 'https://api.myapp.com']
CORS_ALLOW_CREDENTIALS = TrueSSL/TLS Configuration
SSL termination is handled by reverse proxy (nginx, Cloudflare, traefik). Default settings:
SECURE_SSL_REDIRECT = False # Proxy handles redirects
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SESSION_COOKIE_SECURE = True # Production only
CSRF_COOKIE_SECURE = True # Production onlySet ssl_redirect: bool = True only for bare metal Django without reverse proxy (causes redirect loops with nginx/Cloudflare).
CORS Configuration
CORS origins are auto-generated from security_domains. Add custom headers:
class MyConfig(DjangoConfig):
security_domains: list = ["myapp.com", "api.myapp.com"]
cors_allow_headers: list = ['x-api-key', 'x-custom-header'] # Merged with defaultsExamples
Production
class ProductionConfig(DjangoConfig):
secret_key: str = os.environ["SECRET_KEY"]
debug: bool = False
security_domains: list = ["myapp.com", "api.myapp.com"]Development
class DevelopmentConfig(DjangoConfig):
secret_key: str = "dev-secret-key-minimum-50-characters-long-string"
debug: bool = True
# security_domains not needed - auto-configuredEnvironment-Specific Security
# config.production.yaml
secret_key: "${SECRET_KEY}"
debug: false
security_domains: [myapp.com, api.myapp.com]
# config.development.yaml
secret_key: "dev-secret-key-minimum-50-chars"
debug: trueSecurity Best Practices
| Practice | Requirement |
|---|---|
| SECRET_KEY | 50+ chars, from os.environ["SECRET_KEY"] |
| Secrets | Never hardcode, use environment variables |
| SSL | Handle at reverse proxy (nginx/Cloudflare) |
| ALLOWED_HOSTS | Use security_domains, never ['*'] in production |
| CORS | Auto-configured from security_domains |
Generate SECRET_KEY:
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"JWT Authentication
Django-CFG ships JWT auth (via SimpleJWT) configured through JWTConfig, with a
secure-by-default posture — you usually don’t set anything:
from django_cfg import DjangoConfig
from django_cfg.models.api.jwt import JWTConfig
class MyConfig(DjangoConfig):
jwt = JWTConfig() # 30-min access, 90-day refresh, rotation + blacklist on| Setting | Default | Why |
|---|---|---|
access_token_lifetime_minutes | 30 | Short — a leaked access token expires fast. |
refresh_token_lifetime_days | 90 | Long — users “log in once”, stay logged in. |
rotate_refresh_tokens | True | Each refresh issues a new refresh token. |
blacklist_after_rotation | True | A reused (stolen) refresh token is revoked. |
dpop_enabled | False | Opt-in sender-constrained tokens (see below). |
There is a single lifetime knob for access — minutes (the old
_hoursfield was removed).Nonemeans “max” (1 year). Override only if you must:JWTConfig(access_token_lifetime_minutes=15, refresh_token_lifetime_days=30).
Security principles we follow
The design follows the same layered model used by large SaaS platforms — each layer reduces a different risk, so no single failure is catastrophic:
- Short access + long refresh. The access token (sent on every request) is short-lived; the refresh token (used rarely) is long-lived. A stolen access token is useless within minutes; the refresh token is rotated + blacklisted on use, so replay is detected.
- Attack the XSS root, not just the symptom. A baseline Content-Security-
Policy +
X-Content-Type-Options/Referrer-Policy/X-Frame-Optionsis emitted on every frontend route (via@djangocfg/nextjs), so injected scripts are far less likely to run in the first place. - Bind the token to the client (DPoP). When enabled, even an XSS-stolen token can’t be used elsewhere — see below.
- Clean console for users, full logs for admins. Verbose logs are gated by role at runtime; errors always reach the monitoring backend regardless.
DPoP — make a stolen token useless (RFC 9449)
dpop_enabled=True turns on sender-constrained tokens. The browser holds a
non-extractable key (Web Crypto, cannot be read even by your own JS) and signs
a fresh proof per request; the backend binds the issued token to that key
(cnf.jkt) and rejects any request whose proof doesn’t match.
Mental model: the token is a club pass stamped “belongs to the owner of THIS stamp”; the stamp (private key) is glued to your browser and can’t be copied, so a stolen pass is worthless. Full flow + diagrams: JWT Configuration → DPoP.
jwt = JWTConfig(dpop_enabled=True)Pair it with the frontend flag createBaseNextConfig({ dpop: true }). Result: an
attacker who steals the token (via XSS, logs, a copied cURL) cannot use it —
they lack the private key, and any proof they forge is rejected. No BFF/proxy
is required, so the frontend keeps calling Django directly even across origins.
CLI / server-to-server clients (tokens without cnf, or X-API-Key) are
unaffected — they take the plain Bearer path. For scripting the API, use an
API key (X-API-Key), not a browser token. Full guide:
@djangocfg/nextjs → @docs/DPOP.md.
Security Headers
| Header | Production | Development |
|---|---|---|
X_FRAME_OPTIONS | DENY | SAMEORIGIN |
SECURE_HSTS_SECONDS | 31536000 | - |
SECURE_CONTENT_TYPE_NOSNIFF | True | False |
Troubleshooting
ALLOWED_HOSTS Error
Error:
DisallowedHost at /
Invalid HTTP_HOST header: 'newdomain.com'Solution:
class MyConfig(DjangoConfig):
security_domains: list = [
"myapp.com",
"newdomain.com", # Add new domain
]CORS Error
Error:
Access to fetch at 'https://api.myapp.com' from origin 'https://myapp.com'
has been blocked by CORS policySolution:
class MyConfig(DjangoConfig):
security_domains: list = [
"myapp.com",
"api.myapp.com", # Add API domain
]SSL Redirect Loop
Problem: Infinite redirect loop in production
Solution:
class MyConfig(DjangoConfig):
security_domains: list = ["myapp.com"]
ssl_redirect: bool = True
# Add to settings:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')Django-Axes: Brute-Force Protection
Automatic brute-force protection with environment-aware defaults. No configuration required.
| Setting | Development | Production |
|---|---|---|
AXES_FAILURE_LIMIT | 10 | 5 |
AXES_COOLOFF_TIME | 1 hour | 24 hours |
AXES_VERBOSE | True | False |
Custom Configuration
axes: AxesConfig = AxesConfig(
failure_limit=3,
cooloff_time=48,
lockout_template="account/locked.html",
)Configuration Options
AxesConfig Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| Basic Settings | |||
enabled | bool | True | Enable/disable axes protection |
failure_limit | int | None | None | Max failures before lockout (auto: 10 dev, 5 prod) |
cooloff_time | int | None | None | Lockout duration in hours (auto: 1 dev, 24 prod) |
lock_out_at_failure | bool | True | Lock out after reaching failure limit |
reset_on_success | bool | True | Reset failure count after successful login |
| UI/UX Settings | |||
lockout_template | str | None | None | Custom lockout template path |
lockout_url | str | None | None | Custom lockout URL redirect |
| Logging Settings | |||
verbose | bool | None | None | Verbose logging (auto: True dev, False prod) |
enable_access_failure_log | bool | True | Log access failures for security audit |
| IP Filtering | |||
allowed_ips | list[str] | [] | IP addresses that bypass axes protection |
denied_ips | list[str] | [] | IP addresses that are always blocked |
| Proxy Settings | |||
ipware_proxy_count | int | 1 | Number of proxies between client and server |
ipware_meta_precedence_order | list[str] | ["HTTP_X_FORWARDED_FOR", ...] | Order of headers to extract real IP |
cache_name | str | "default" | Cache backend to use |
username_form_field | str | "username" | Form field to track (e.g., "email") |
Proxy Configuration
axes = AxesConfig(
ipware_proxy_count=2, # Number of proxies (1 for Cloudflare, 2 for Cloudflare+nginx)
ipware_meta_precedence_order=[
'HTTP_CF_CONNECTING_IP', # Cloudflare
'HTTP_X_FORWARDED_FOR', # nginx/traefik
'HTTP_X_REAL_IP',
'REMOTE_ADDR',
],
)Custom Lockout
# Option 1: Template
axes = AxesConfig(lockout_template="account/locked.html")
# Option 2: URL redirect
axes = AxesConfig(lockout_url="/account/locked/")Admin panel includes Access Attempts, Access Logs, and Access Failures for viewing/unlocking accounts.
Examples
# Strict: 3 attempts, 72h lockout
axes = AxesConfig(failure_limit=3, cooloff_time=72)
# With IP whitelist
axes = AxesConfig(failure_limit=5, allowed_ips=['192.168.1.0/24'])Troubleshooting
| Problem | Solution |
|---|---|
| False lockouts | Increase failure_limit or add allowed_ips |
| All requests from proxy IP | Configure ipware_proxy_count and ipware_meta_precedence_order |
| Unlock user | python manage.py axes_reset username admin or delete in Admin |
See Also
- DjangoConfig - Base configuration class
- Configuration Overview - Configuration system overview
- Environment - Environment detection