DEV Community

Cover image for Why the Django admin says 403 behind a proxy, and the one line that fixes it
John Bannister
John Bannister

Posted on

Why the Django admin says 403 behind a proxy, and the one line that fixes it

So you put a Django app behind a reverse proxy that handles HTTPS (nginx, Traefik, Caddy, a PaaS router, a load balancer), open /admin/, type the right username and password, and get this:

Forbidden (403)
CSRF verification failed. Request aborted.

Origin checking failed - https://myapp.example does not match any trusted origins.
Enter fullscreen mode Exit fullscreen mode

The username and password are fine (checked umpteen times). Django just thinks the request came in over plain HTTP.

Why it happens

The proxy normally ends HTTPS and passes the request on to Django over HTTP, adding a header that says what the original scheme was:

X-Forwarded-Proto: https
Enter fullscreen mode Exit fullscreen mode

Django will ignore that header unless you tell it to trust it. So request.is_secure() is False, and Django expects the browsers Origin to be http://myapp.example. The browser sends https://myapp.example, they don't match, and since Django 4.0 that mismatch is a 403 on every POST, including the admin login.

The fix

In settings.py:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
Enter fullscreen mode Exit fullscreen mode

Now Django treats requests with X-Forwarded-Proto: https as secure, the expected origin becomes https://..., and the login works.

Caveat: only set this when your proxy always sets (or overwrites) X-Forwarded-Proto itself. If clients can reach Django directly and send that header themselves, they can make a plain HTTP request look secure. The Django docs say as much.

CSRF_TRUSTED_ORIGINS: when you need it

You don't need it for the case above. You need it when forms are posted from another origin (a separate frontend domain, for example), and since Django 4.0 the entries need the scheme:

CSRF_TRUSTED_ORIGINS = ["https://myapp.example"]   # not "myapp.example"
Enter fullscreen mode Exit fullscreen mode

Leaving out https:// is a common reason it "doesn't work" after an upgrade.

While you're in settings.py: the database from the environment

Most hosts hand you the database as environment variables, so no password has to sit in the code:

import os

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"),
    }
}
Enter fullscreen mode Exit fullscreen mode

If you get one DATABASE_URL instead, django-environ makes it one line:

import environ

env = environ.Env()
DATABASES = {"default": env.db("DATABASE_URL")}
Enter fullscreen mode Exit fullscreen mode

And list psycopg[binary] in your requirements, or the first migrate fails on the missing driver.

Summary

  • 403 "Origin checking failed" behind an HTTPS proxy: SECURE_PROXY_SSL_HEADER, as long as the proxy controls X-Forwarded-Proto
  • CSRF_TRUSTED_ORIGINS only for cross-origin posts, and always with https://
  • Database settings from the environment, not in the code

I hit all of this while writing a step-by-step guide for Uppcoder, the platform I work on: Deploy a Django app with PostgreSQL, free. There's also a small starter project with these settings already in place: websson/django-postgresql-starter.

Hopefully this will save anyone facing the same or similar stack install some time and nerves.

Top comments (0)