Skip to Content

Security Settings

Production requirements: Strong SECRET_KEY (50+ chars), reverse proxy with SSL, specific security_domains, environment variables for secrets.

Environment Modes

SettingDevelopmentProduction
CORSAll origins allowedStrict whitelist from security_domains
ALLOWED_HOSTS['*']Generated from security_domains
CSRFDev ports (3000, 5173, 8080, etc.)Trusted origins from domains
DEBUGEnabledForced disabled
SSLDisabledReverse 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 development

Auto-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 = True

SSL/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 only

Set 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 defaults

Examples

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-configured

Environment-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: true

Security Best Practices

PracticeRequirement
SECRET_KEY50+ chars, from os.environ["SECRET_KEY"]
SecretsNever hardcode, use environment variables
SSLHandle at reverse proxy (nginx/Cloudflare)
ALLOWED_HOSTSUse security_domains, never ['*'] in production
CORSAuto-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
SettingDefaultWhy
access_token_lifetime_minutes30Short — a leaked access token expires fast.
refresh_token_lifetime_days90Long — users “log in once”, stay logged in.
rotate_refresh_tokensTrueEach refresh issues a new refresh token.
blacklist_after_rotationTrueA reused (stolen) refresh token is revoked.
dpop_enabledFalseOpt-in sender-constrained tokens (see below).

There is a single lifetime knob for access — minutes (the old _hours field was removed). None means “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:

  1. 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.
  2. Attack the XSS root, not just the symptom. A baseline Content-Security- Policy + X-Content-Type-Options/Referrer-Policy/X-Frame-Options is emitted on every frontend route (via @djangocfg/nextjs), so injected scripts are far less likely to run in the first place.
  3. Bind the token to the client (DPoP). When enabled, even an XSS-stolen token can’t be used elsewhere — see below.
  4. 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

HeaderProductionDevelopment
X_FRAME_OPTIONSDENYSAMEORIGIN
SECURE_HSTS_SECONDS31536000-
SECURE_CONTENT_TYPE_NOSNIFFTrueFalse

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 policy

Solution:

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.

SettingDevelopmentProduction
AXES_FAILURE_LIMIT105
AXES_COOLOFF_TIME1 hour24 hours
AXES_VERBOSETrueFalse

Custom Configuration

axes: AxesConfig = AxesConfig( failure_limit=3, cooloff_time=48, lockout_template="account/locked.html", )

Configuration Options

AxesConfig Parameters

ParameterTypeDefaultDescription
Basic Settings
enabledboolTrueEnable/disable axes protection
failure_limitint | NoneNoneMax failures before lockout (auto: 10 dev, 5 prod)
cooloff_timeint | NoneNoneLockout duration in hours (auto: 1 dev, 24 prod)
lock_out_at_failureboolTrueLock out after reaching failure limit
reset_on_successboolTrueReset failure count after successful login
UI/UX Settings
lockout_templatestr | NoneNoneCustom lockout template path
lockout_urlstr | NoneNoneCustom lockout URL redirect
Logging Settings
verbosebool | NoneNoneVerbose logging (auto: True dev, False prod)
enable_access_failure_logboolTrueLog access failures for security audit
IP Filtering
allowed_ipslist[str][]IP addresses that bypass axes protection
denied_ipslist[str][]IP addresses that are always blocked
Proxy Settings
ipware_proxy_countint1Number of proxies between client and server
ipware_meta_precedence_orderlist[str]["HTTP_X_FORWARDED_FOR", ...]Order of headers to extract real IP
cache_namestr"default"Cache backend to use
username_form_fieldstr"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

ProblemSolution
False lockoutsIncrease failure_limit or add allowed_ips
All requests from proxy IPConfigure ipware_proxy_count and ipware_meta_precedence_order
Unlock userpython manage.py axes_reset username admin or delete in Admin

See Also

Last updated on