How to secure a Django app before production

Tue Aug 11 2026

SECURE_PROXY_SSL_HEADER can fix HTTPS detection or, if your proxy passes client headers through, make an attacker-controlled header part of your security model.

But that boundary is where most Django checklists fall short. DEBUG = False is necessary, but it isn’t a security plan; a broken proxy, media path, or secrets pipeline can leave the deployed app exposed. Django gives you strong defaults for common attacks. Your deployment has to preserve them.

So follow this Django security checklist in order: audit production settings, secure browser traffic and identity, review application escape hatches, then test nginx, Gunicorn, uploaded media, private services, alerts, and backups.

In this article

Before you change code, run Django’s deployment audit

So run Django’s deployment check against the settings module your production process will use:

python manage.py check --deploy --settings=config.settings.production

check --deploy is a smoke alarm, not a fire inspection. It catches many deployment-setting warnings, including debug mode, key configuration, insecure cookies, missing HTTPS redirects, HSTS, and host configuration gaps. It cannot inspect whether your load balancer strips client-supplied X-Forwarded-Proto, whether nginx rejects unknown hosts, whether PostgreSQL is publicly reachable, or whether /media/ can execute an uploaded file.

And it cannot determine whether an issue-detail view forgot an object-level authorization check. Django can inspect configuration; it cannot infer your application’s security intent.

Use the command as the first gate:

  1. Configure: fix every warning that applies to production.
  2. Inspect: review the proxy, web server, database, cache, storage, and alerting configuration.
  3. Verify: send requests to the deployed service and record their responses.

And the Django deployment checklist gives you the baseline. The useful work starts where its automated checks stop.

Put production secrets outside the repository

A leaked SECRET_KEY may compromise signed sessions, password-reset tokens, or other data protected with that key, depending on how your application uses it. Database credentials deserve the same treatment and should be usable only from approved application infrastructure.

Django requires a large, random, secret key. Generate one with Django:

python -m django shell -c \
  "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

And read it from the environment or a protected file:

# config/settings/production.py
import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["POSTGRES_DB"],
        "USER": os.environ["POSTGRES_USER"],
        "PASSWORD": os.environ["POSTGRES_PASSWORD"],
        "HOST": os.environ["POSTGRES_HOST"],
        "PORT": os.environ.get("POSTGRES_PORT", "5432"),
    }
}

A missing required secret should stop startup. A server that silently falls back to a development key turns a deployment mistake into a live vulnerability.

For staged rotation, use SECRET_KEY_FALLBACKS:

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]

SECRET_KEY_FALLBACKS = [
    key.strip()
    for key in os.environ.get("DJANGO_SECRET_KEY_FALLBACKS", "").split(",")
    if key.strip()
]

Use fallbacks during a short migration window, then remove old keys promptly. Keeping every historical key forever turns rotation into theater.

ReviewAction
Repository and manifestsSearch for keys, passwords, tokens, and database URLs
Deployment processConfirm secrets arrive through the platform or protected files
RotationDocument replacement, fallback duration, and revocation
Failure behaviorMake missing required secrets stop startup
git grep -nE "SECRET_KEY|PASSWORD|DATABASE_URL|TOKEN|API_KEY"

So deploy once with a deliberately missing secret in a non-production environment. The process should fail before serving traffic. Check startup logs. Confirm the intended secret value never appears.

Make Django fail closed in production

But production settings should be explicit. Do not depend on a development file being “mostly overridden.”

SettingProduction directionRisk reduced
DEBUGFalseDebug pages exposing source, settings, and local variables
ALLOWED_HOSTSExact deployed hostnamesHost-header attacks and unintended host acceptance
SECRET_KEYSecret, random, environment-loaded valueForged signed data
SESSION_COOKIE_SECURETrueSession transmission over HTTP
CSRF_COOKIE_SECURETrueCSRF-cookie transmission over HTTP
SECURE_SSL_REDIRECTTrue when Django sees HTTPAccidental HTTP access

So for a small issue tracker at app.example.com, nginx can terminate HTTPS and proxy to Gunicorn:

# config/settings/production.py
DEBUG = False

ALLOWED_HOSTS = [
    "app.example.com",
]

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True

Replace the hostname with the exact names your users will access. ALLOWED_HOSTS = ["*"] is a shortcut that moves the risk somewhere else. If you can’t name and test that other validator, don’t use it.

With DEBUG=False, Django requires a suitable ALLOWED_HOSTS value. Debug responses can expose source excerpts, settings, local variables, and library details.

Check the process command and the file it uses:

ps aux | grep '[g]unicorn'

Then request an invalid host through the public route. A valid request should reach the application; an unknown host should receive a rejection rather than a normal application response.

Treat Host headers as an input boundary

Django validates the Host header through request.get_host() against ALLOWED_HOSTS. Direct raw metadata access bypasses that protection.

# Validated by Django
host = request.get_host()
# Raw request input
host = request.META["HTTP_HOST"]

Search for both patterns:

git grep -nE "HTTP_HOST|USE_X_FORWARDED_HOST|get_host"

Use request.get_host() for host validation, then avoid using host input for tenant selection or redirects unless the resulting value is separately allow-listed. Raw host values can influence absolute URLs and redirect targets. They can affect password-reset links, tenant routing and origin checks.

USE_X_FORWARDED_HOST = True changes the host source Django uses. Enable it only when a known proxy sets that header and removes untrusted client values.

Test the edge and application separately. For HTTPS, matching SNI matters as well as the Host header:

curl -i --resolve app.example.com:443:SERVER_IP \
  https://app.example.com/

Repeat with an unexpected Host header against the public IP and, where permitted, the origin. The unknown host should hit the rejection path.

Enforce HTTPS for the entire site

Any site that accepts logins should enforce HTTPS everywhere. Session cookies, password-reset tokens and access tokens carry credentials. Protecting only /login/ or /admin/ still leaves an authenticated browser session available to HTTP requests elsewhere.

Configure Django and the outer web server:

SECURE_SSL_REDIRECT = True

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

# Initial deployment value while HTTPS coverage is being verified.
SECURE_HSTS_SECONDS = 0
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False

Keep HSTS in staging while SECURE_HSTS_SECONDS remains 0; this setting is for staging, while the finished setting uses a longer duration. Increase it only after every relevant hostname works over HTTPS. includeSubDomains covers subdomains. Preload creates a longer-lived commitment.

The web server should redirect every HTTP request to HTTPS before proxying application content. Check the redirect chain:

curl -I http://app.example.com/
curl -I https://app.example.com/

The HTTP response should redirect without rendering authenticated content. The HTTPS response should include HSTS once enabled. HSTS delivered over HTTP does not establish the policy.

In a browser, log in over HTTPS and inspect the session and CSRF cookies. Both should carry Secure. Record the session cookie’s HttpOnly and SameSite values too. Custom cookies created with set_cookie() need appropriate flags too.

Do not blindly trust SECURE_PROXY_SSL_HEADER

Do not paste SECURE_PROXY_SSL_HEADER into production settings because a blog told you to. I can tell you what to verify here; I can’t infer your proxy’s trust boundary from Django settings alone.

A reverse proxy creates a trust contract:

browser
  │ HTTPS

TLS-terminating proxy
  │ X-Forwarded-Proto: https

Gunicorn → Django

This setting is appropriate only when:

  1. The outer proxy terminates TLS.
  2. It sets X-Forwarded-Proto: https for HTTPS requests.
  3. It strips or replaces any client-supplied X-Forwarded-Proto.
  4. Gunicorn is reachable only through that trusted path.
  5. Every proxy in front of Django follows the same contract.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

If the proxy fails to set the header, SECURE_SSL_REDIRECT can redirect an already secure browser request repeatedly. Secure-request CSRF referer checks can also fail because Django believes the request is HTTP.

A practical diagnostic should expose the effective scheme only to an authorized operator:

proxy access log: forwarded_proto=https
Django diagnostic: request.is_secure()=True
response: no redirect

Temporarily and safely record the header value received at the proxy boundary. Then run the spoofing test against the public edge:

curl -i -H 'X-Forwarded-Proto: http' https://app.example.com/

Compare the proxy’s received-header log with the value forwarded to Gunicorn. Check the diagnostic’s request.is_secure() result too. The client-supplied value must be stripped or replaced before Django sees it. Where permitted, repeat the test against the origin. Gunicorn should be reachable only through the trusted network path.

The Django middleware reference documents the proxy caveat. In many deployments, redirecting HTTP at the main web server is easier and safer because that component already knows the TLS state.

Keep CSRF protection intact in forms and AJAX

Django’s CsrfViewMiddleware protects unsafe methods such as POST, PUT, PATCH, and DELETE. Keep it in MIDDLEWARE:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    # ...
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

Include a token in internal forms:

<form method="post" action="{% url 'issues:create' %}">
  {% csrf_token %}
  <label>
    Title
    <input name="title">
  </label>
  <button type="submit">Create issue</button>
</form>

The following AJAX example assumes a rendered form supplies the DOM token:

const token = document.querySelector(
  "[name=csrfmiddlewaretoken]"
).value;

fetch("/issues/42/comment/", {
  method: "POST",
  credentials: "same-origin",
  headers: {
    "X-CSRFToken": token,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ body: "Needs reproduction steps." })
});

If your page does not render a form, use Django’s documented cookie-reading pattern where permitted by your cookie settings. CSRF_COOKIE_HTTPONLY=True changes that procedure: JavaScript cannot read the cookie, so a server-rendered DOM token or another deliberate delivery path is required.

Secure requests also receive an HTTPS referer check. A wrong proxy scheme or host configuration can therefore look like a CSRF defect.

RequestExpected result
POST without tokenCSRF rejection
POST with valid form tokenSuccess
AJAX POST with token and same-origin credentialsSuccess
PUT, PATCH, or DELETE without tokenCSRF rejection
Cross-origin state-changing requestRejected unless deliberately designed and protected
csrf_exempt endpointSeparate authentication and integrity test

Review every csrf_exempt occurrence. Each exemption needs a narrow reason and a focused test.

Harden authentication where attackers apply pressure

Use django.contrib.auth, Django sessions, and password validators instead of inventing replacements:

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": (
            "django.contrib.auth.password_validation."
            "UserAttributeSimilarityValidator"
        ),
        "OPTIONS": {"max_similarity": 0.7},
    },
    {
        "NAME": (
            "django.contrib.auth.password_validation."
            "MinimumLengthValidator"
        ),
        "OPTIONS": {"min_length": 8},
    },
    {
        "NAME": (
            "django.contrib.auth.password_validation."
            "CommonPasswordValidator"
        ),
    },
    {
        "NAME": (
            "django.contrib.auth.password_validation."
            "NumericPasswordValidator"
        ),
    },
]

Django’s password helpers hash and verify passwords:

from django.contrib.auth.hashers import check_password, make_password

stored = make_password(raw_password)
valid = check_password(raw_password, stored)

Keep raw passwords out of logs, emails, analytics, and exception messages.

Inspect protected views and URL patterns for @login_required or equivalent access control. Authentication proves identity. Object-level authorization must confirm that the user may access the requested issue or attachment.

Password validators do not slow thousands of login attempts. Add one maintained control, such as django-ratelimit or django-axes, to login, password reset, and other sensitive flows. Choose one and verify its actual behavior rather than assuming the package’s default response is suitable.

The OWASP Django Security Cheat Sheet recommends changing the default admin URL to reduce automated probing. Changing /admin/ reduces automated probing, while authentication still depends on real access controls. Treat it as the last inch of the defense.

MFA is my recommendation for sensitive and administrator accounts. DjangoZen also presents MFA as an authentication-hardening measure, but Django core does not provide a complete MFA policy. Enforce it through an application package or identity provider, then test enrollment, recovery, session invalidation, and administrator enforcement.

After the configured threshold, the login flow must stop accepting unlimited attempts. Record the status, response, and reset behavior for your chosen control. Also attempt a protected administrative route as a normal user and confirm authorization rejection.

Let Django’s defaults handle SQL injection until you bypass them

A normal queryset keeps user input as a lookup value:

def find_issues(term):
    return Issue.objects.filter(title__icontains=term)

Django’s queryset API parameterizes lookup values. Raw SQL remains possible, but pass values through the driver’s parameter argument.

This is a PostgreSQL example because ILIKE is PostgreSQL syntax:

from django.db import connection

def find_issues(term):
    with connection.cursor() as cursor:
        cursor.execute(
            """
            SELECT id, title
            FROM app_issue
            WHERE title ILIKE %s
            """,
            [f"%{term}%"],
        )
        return cursor.fetchall()

Parameters protect values. They do not make a user-supplied table name or column name safe. Ordering clauses and SQL keywords need the same treatment. Take those choices from fixed allow-lists:

ORDERING = {
    "newest": "created_at DESC",
    "oldest": "created_at ASC",
}

order_sql = ORDERING.get(request.GET.get("order"), "created_at DESC")

Review raw(), RawSQL, extra(), custom managers, report queries, cursor.execute(), and database functions:

git grep -nE "raw\(|RawSQL|extra\(|cursor\(|execute\("

For each result, confirm that values use parameters and identifiers come from controlled code. Authorization is a separate review.

Preserve template escaping instead of fixing output by hand

Django templates escape dangerous HTML characters by default, but context-specific output still needs review. Keep untrusted values out of JavaScript and CSS. Handle HTML attributes and other contexts with the appropriate escaping.

Review every deliberate escape hatch:

  • |safe
  • mark_safe()
  • is_safe
  • {% autoescape off %}

These may be correct for trusted, deliberately constructed markup. They are dangerous for issue titles, comments, usernames, uploaded metadata, and other user-influenced values.

Use json_script when passing structured data to JavaScript:

{{ issue_data|json_script:"issue-data" }}

<script>
  const issue = JSON.parse(
    document.getElementById("issue-data").textContent
  );
</script>

Test an issue comment containing angle brackets, quotes, and line breaks. It should render as text, and the JavaScript data path should parse without creating executable markup.

CSP earns its place after the boring controls work. Shipping a complicated policy before you understand your scripts creates false confidence. Django has no built-in CSP; add a maintained package such as django-csp or configure the response header yourself.

My recommended rollout is:

  1. Send Content-Security-Policy-Report-Only and filter legitimate reports.
  2. Fix the remaining violations.
  3. Enforce Content-Security-Policy.

Verify the deployed response and browser behavior:

curl -sS -D - -o /dev/null https://app.example.com/

Then confirm that an intentionally disallowed script is blocked once enforcement begins.

Add the headers Django can provide, and know what it cannot

Risk: response headers reduce clickjacking, MIME confusion, referrer leakage, and cross-origin window risks. They are defense in depth, not a substitute for safe output or HTTPS.

The Django security documentation and OWASP’s Django guidance cover the framework middleware and header controls. Record the actual response from each serving layer.

HeaderConfigurationThreat reducedVerification
X-Content-Type-Options: nosniffSecurityMiddleware; SECURE_CONTENT_TYPE_NOSNIFF = TrueMIME-type confusioncurl -I
X-Frame-OptionsXFrameOptionsMiddleware; X_FRAME_OPTIONS = "DENY" or "SAMEORIGIN"ClickjackingHeader and frame test
Referrer-PolicySet this response header in Django or nginxReferrer information leakageInspect deployed response
Cross-Origin-Opener-PolicySet this response header in Django or nginxCross-origin window isolationInspect deployed response
Content-Security-PolicySeparate package or deliberate response headerLimits some XSS impactBrowser console and policy reports

A baseline might look like this:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    # ...
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"

Choose SAMEORIGIN if the application deliberately embeds pages in same-origin frames. Static files served directly by nginx never pass through Django middleware, so inspect those responses separately:

curl -sS -D - -o /dev/null https://app.example.com/
curl -sS -D - -o /dev/null https://app.example.com/static/app.css
curl -sS -D - -o /dev/null https://app.example.com/missing-page

Uploaded media is untrusted and must not execute

Risk: an upload can become a server-side execution path or expose private issue data if the web server and authorization boundary are wrong.

Keep application code, collected static files, and user media separate:

STATIC_ROOT = "/srv/app/static/"
MEDIA_ROOT = "/srv/app/media/"
STATIC_URL = "/static/"
MEDIA_URL = "/media/"

For the issue tracker, attachments contain potentially private issue data. For private attachments, authorize every download before serving it. I can’t tell from this checklist whether your attachment authorization is correct; test it with two real users and a private file.

nginx should serve /static/ and /media/ as files, while Gunicorn serves application routes. The media path must not overlap the Python source tree or any script-capable document root in your chosen web-server configuration. Review the server’s handlers:

sudo nginx -T

Inspect the /media/ location and verify that it has no FastCGI, CGI, uWSGI, proxy, or equivalent interpreter handler. Then upload a plain-text fixture in staging and request it through the deployed media path. The response should be an ordinary file response, never an application execution path.

If private attachments should download rather than render, verify your chosen Content-Disposition; Django’s media settings do not enforce it.

Make the web server reject unknown hosts before Django sees them

Risk: an explicit default virtual host prevents unknown Host values from reaching the application’s normal routing path.

Django’s ALLOWED_HOSTS remains required. nginx should also have explicit default behavior for unmatched virtual hosts. The official HTTP pattern is:

server {
    listen 80 default_server;
    server_name _;
    return 444;
}

For HTTPS, configure an explicit default TLS server according to your nginx version, certificate layout, and certificate-selection requirements. Do not copy certificate paths from this article as universal values. The important property is that an unmatched TLS virtual host has deliberate behavior and does not proxy arbitrary traffic to the application.

Test HTTP and HTTPS separately. For HTTPS, use matching SNI:

curl -i -H 'Host: unknown.example' http://SERVER_IP/
curl -i --resolve app.example.com:443:SERVER_IP \
  https://app.example.com/

The unknown HTTP host should hit nginx’s rejection path. The valid HTTPS hostname should reach the intended TLS virtual host.

Check the production services Django assumes are private

Risk: a browser-facing application can still be compromised through an exposed database, cache, stale session, missing static collection, or untested backup.

ServiceConfigure and inspectVerification
PostgreSQLRestrict access to application servers; protect the passwordConnect from an unauthorized network and expect refusal
Redis or cacheRestrict access to application servers; review authenticationAttempt an unauthorized connection and expect refusal
Static filesSet STATIC_ROOT; run collectstaticRequest a known asset and inspect its serving layer
SessionsIdentify the backend and expiry behaviorLog out, then request a protected resource
BackupsDefine retention and an ownerRestore into isolation and record the result

For the issue tracker, Redis may hold sessions and cache data while PostgreSQL stores issues and users. Inspect the Gunicorn bind address and Unix-socket permissions. Review the security groups and firewall rules, then test from an unauthorized network. “The port is not in the application settings” is not evidence.

If you use database-backed sessions, identify the cleanup job required by your deployment and schedule it. For other backends, verify their expiry and eviction behavior separately. Cached sessions, persistent database connections, and Django’s cached template loader when DEBUG=False are operational checks; they do not replace network restrictions.

Restore the latest backup into an isolated database, run a known query or migration check, and record the restore duration, timestamp, and result.

Configure failure reporting without leaking failure details

Risk: users should receive generic production errors while maintainers receive enough information to respond.

ADMINS = [
    ("Operations", "ops@example.com"),
]
MANAGERS = ADMINS

DEFAULT_FROM_EMAIL = "app@example.com"
SERVER_EMAIL = "server@example.com"

IGNORABLE_404_URLS = [
    # Add only known noisy patterns, such as a documented scanner path.
]

ADMINS receives server-error notifications. MANAGERS receives 404 notifications. Email reporting does not scale well. Use a scalable error-monitoring service when volume or response time requires it.

Scrub Authorization, cookies, and passwords before forwarding events. Remove reset tokens, database credentials, and uploaded content too. Keep that review in the monitoring configuration rather than assuming a collector is safe for raw request bodies.

Test in staging, or through an authenticated temporary maintenance mechanism that is removed before launch. Trigger a known 500 and request a known missing URL. The user-facing responses should contain no debug page; the alerts should reach the intended channel.

Verify the deployed application on release day

The final pass should follow the deployed route, not read back the settings file.

Run the automated gate:

python manage.py check --deploy --settings=config.settings.production

Then record the deployed responses:

curl -I http://app.example.com/
curl -sS -D - -o /dev/null https://app.example.com/
curl -i -H 'Host: unexpected.example' http://SERVER_IP/
curl -i --resolve app.example.com:443:SERVER_IP \
  https://app.example.com/

Use the records to mark these checks:

[ ] The production settings module is used by web, worker, migration, and audit commands.
[ ] Secrets are external, absent from source and logs, and rotation is documented.
[ ] DEBUG=False and exact ALLOWED_HOSTS are active.
[ ] HTTP redirects to HTTPS; session cookies show Secure, HttpOnly, and SameSite.
[ ] CSRF failures and valid form/AJAX requests behave as expected.
[ ] Login throttling stops unlimited attempts; MFA is enforced for designated accounts.
[ ] Raw SQL and template escape hatches have named owners and passing tests.
[ ] HTTPS responses include HSTS when enabled, nosniff, X-Frame-Options, Referrer-Policy, and COOP as configured.
[ ] nginx rejects unknown hosts; Gunicorn is private; media has no interpreter handler.
[ ] Private attachments reject an unauthorized user.
[ ] PostgreSQL and Redis reject unauthorized network clients.
[ ] Static files, error alerts, and narrowly scoped 404 filtering work.
[ ] A database restore was completed and recorded.
[ ] Deployed Django and dependency versions are supported and known vulnerabilities were reviewed.

Postpone launch if any boundary check lacks recorded evidence. Assign an owner. A green settings diff is not evidence.