Fix Django Login Not Persisting Behind HTTPS

Fix Django logins that do not stick behind a proxy or HTTPS: secure cookies, HTTPS detection, cookie domains, SameSite, and shared session backends.

Problem statement

Users submit the login form, the credentials are correct, and they are redirected back to the login page, or logged out on the very next click. The admin shows the same behavior: "Please enter the correct username and password" never appears, yet the session does not persist. In development everything works.

When a login "does not stick", Django did authenticate the user and created a session, but the browser did not send the session cookie back on the next request, or the server could not find the session when it did. The usual causes in production:

  • secure cookies on a site Django thinks is HTTP, or on a site actually served over HTTP
  • a SESSION_COOKIE_DOMAIN that does not match the host
  • SameSite settings that block the cookie in a cross-site flow (for example after an external login provider redirect)
  • a session backend that is not shared across servers or workers (local memory cache)
  • a proxy or CDN stripping or caching Set-Cookie headers
A session round tripLogin viewcreates sessionSet-CookieSecure, SameSiteBrowserstores or dropsNext requestsession found?Cookie attributes and a shared session store decide whether the login sticks.

Quick answer

  1. In the browser's developer tools, inspect the login response's Set-Cookie header and whether the browser stored sessionid.
  2. If the cookie is marked Secure but the page is HTTP, serve the site over HTTPS or fix the proxy scheme detection with SECURE_PROXY_SSL_HEADER.
  3. Check SESSION_COOKIE_DOMAIN: None (host-only) is safest unless you need subdomain sharing.
  4. Use a shared session backend (db, cached_db with Redis, or signed cookies); never LocMemCache with multiple processes.
  5. Make sure no CDN or cache stores responses that contain Set-Cookie, and that Nginx does not hide the header.
  6. For cross-site redirects (SSO, payment returns), check SameSite behavior.
  7. Verify with curl and a browser in a private window.

Step-by-step solution

In the browser, open developer tools, go to Network, and log in. Click the POST to the login URL:

  • Response headers: look for Set-Cookie: sessionid=...; ... HttpOnly; Path=/; SameSite=Lax; Secure.
  • Application or Storage tab: check whether sessionid exists for the site after login.
  • Next request's headers: check whether Cookie: sessionid=... is sent.
login succeeds, then vanishesPOST /login/credentials accepted302 + Set-Cookiesessionid; SecureBrowser drops itdomain or scheme mismatchGET /dashboard/no cookie sentBack to loginlooks like a failed loginThe Network tab shows whether the cookie was set, blocked, or never sentdjango-deployment.com
Chrome marks blocked cookies with a warning icon and a reason.

The browser console or the cookie panel often shows a warning icon explaining why a cookie was rejected, for example "This Set-Cookie was blocked because it had the Secure attribute but was not received over a secure connection."

2. Secure cookies and HTTPS detection

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
causes, from most to least commonEvidenceCauseSecure cookie, site seen as HTTPproxy SSL header missingCookie domain differs from hostSESSION_COOKIE_DOMAIN wrongWorks on one server onlyper-process session storageRandom logouts across serversdifferent SECRET_KEY per serverFails only in embedded or SSO flowsSameSite blocks the cookieCheck them in this order; the first is by far the most commondjango-deployment.com
Each row maps to one of the numbered sections.

Browsers only store and send Secure cookies over HTTPS. Two failure modes:

  • The site is actually served over HTTP (for example during initial setup, or a load balancer health path users hit directly). The cookie is dropped. Serve the site over HTTPS before enabling secure cookies.
  • The site is HTTPS, but Django sees HTTP behind a proxy. The cookie itself is still stored (the browser saw HTTPS), but related logic breaks: redirects go to http://, CSRF origin checks fail, and SECURE_SSL_REDIRECT may loop. Tell Django about the proxy:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
proxy_set_header X-Forwarded-Proto $scheme;

If the login redirect goes to http://www.example.com/... and the browser then requests HTTP, the Secure cookie is not sent on that request, so the user appears logged out. Watch the Location header of the login response.

SESSION_COOKIE_DOMAIN = None
CSRF_COOKIE_DOMAIN = None

None makes a host-only cookie for the exact host that set it. Problems appear when:

  • SESSION_COOKIE_DOMAIN = "example.com" is set, but the site is served on www.example.org or a platform subdomain; the browser rejects the cookie for a non-matching domain
  • users log in on example.com and are redirected to www.example.com; a host-only cookie from example.com is not sent to www

Fix host mismatches by redirecting to one canonical host before login, at the proxy. Only set SESSION_COOKIE_DOMAIN = ".example.com" if you intentionally share sessions across subdomains, and be aware that every subdomain then receives the session cookie.

4. Use a shared session backend

Django's default SESSION_ENGINE stores sessions in the database, which all processes share. Problems start when sessions are moved to a cache that is not shared:

SESSION_ENGINE = "django.contrib.sessions.backends.cache"
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
sessions must be shared by every processlocmem cache sessions✕each worker has its own store✕next request hits another worker✕lost on every restartdb, cached_db, or Redis✓one store for all workers✓shared across servers✓survives restartsThe default database backend already works; custom cache setups break itdjango-deployment.com
Pure cache sessions also vanish when the cache evicts or restarts.

LocMemCache is per process. The login request writes the session into worker 1's memory; the next request lands on worker 2, which has no such session, so the user is anonymous. It looks random because it depends on which worker handles each request.

Fix with a shared store:

SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": os.environ["REDIS_URL"],
    }
}

With several servers, the same rule applies across machines: sticky sessions at the load balancer hide the problem until a server restarts or is removed.

If you use the database backend, confirm the django_session table exists (migrations applied) and that expired sessions are cleaned up periodically with python manage.py clearsessions.

5. SECRET_KEY differences between servers

Session authentication includes a hash derived from SECRET_KEY and the user's password hash. If servers behind a load balancer have different SECRET_KEY values, a session created on one server fails validation on another, and the user is logged out. With the signed_cookies session engine, sessions are signed with the key directly. Use the same SECRET_KEY on every instance of the same environment.

Rotating the key without SECRET_KEY_FALLBACKS also logs everyone out once.

  • A CDN or reverse proxy cache that caches the login page or redirect response with its Set-Cookie header can serve one user's session cookie to others, or strip cookies from cached responses. Bypass caching for login, logout, and authenticated paths.
  • Nginx directives such as proxy_hide_header Set-Cookie; (sometimes copied from caching guides) remove cookies from responses. Check the site configuration and included snippets:
sudo nginx -T 2>/dev/null | grep -n -i -E "hide_header|ignore_headers|set-cookie"

7. SameSite in cross-site flows

Django's default SESSION_COOKIE_SAMESITE = "Lax" sends the cookie on top-level navigations from other sites (links, redirects via GET) but not on cross-site POST requests or requests from iframes.

SameSite decides cross-site sendsTop-level link from another siteCross-site POST or iframeStrict✕not sent✕not sentLax (default)✓sent!not sentNone; Secure✓sent✓sentSSO callbacks that POST back need None, and therefore HTTPSdjango-deployment.com
Browsers reject SameSite=None cookies that are not also Secure.

Flows that can break:

  • an external identity provider that returns to your site with an HTTP POST (form post response mode); the session cookie is not sent with that POST, so your callback cannot find the login state
  • your site embedded in an iframe on another domain

Options: use a GET redirect response mode where the provider supports it, or set SameSite=None on the specific cookies involved, which requires Secure and increases CSRF exposure. Avoid changing the session cookie globally to None unless the embedding use case requires it.

Sessions that end when the browser closes (SESSION_EXPIRE_AT_BROWSER_CLOSE = True) or very short SESSION_COOKIE_AGE values look like "login does not persist" to users who reopen the browser. Also check that SESSION_SAVE_EVERY_REQUEST or custom middleware is not flushing sessions.

9. Verify with curl

# Get the login page and CSRF cookie
curl -s -c /tmp/cj -b /tmp/cj https://www.example.com/accounts/login/ -o /tmp/login.html
TOKEN=$(grep -o 'name="csrfmiddlewaretoken" value="[^"]*"' /tmp/login.html | cut -d'"' -f4)

# Log in, keeping cookies
curl -s -c /tmp/cj -b /tmp/cj -o /dev/null -w "%{http_code} %{redirect_url}\n" \
  -H "Referer: https://www.example.com/accounts/login/" \
  --data-urlencode "csrfmiddlewaretoken=$TOKEN" \
  --data-urlencode "username=testuser" \
  --data-urlencode "password=$TEST_PASSWORD" \
  https://www.example.com/accounts/login/

grep sessionid /tmp/cj && curl -s -b /tmp/cj -o /dev/null -w "%{http_code}\n" https://www.example.com/accounts/profile/

Use a dedicated test account. Delete /tmp/cj afterwards; it contains a valid session.

Verification check:

  • the login response sets sessionid with the expected attributes
  • the redirect after login uses https:// and the canonical host
  • an authenticated page returns 200 (not a redirect to login) with the saved cookie
  • repeated requests stay authenticated across all workers and servers

Explanation

Authentication in Django is two-part: the login view stores the user ID and an authentication hash in the session, and the session middleware finds that session on later requests using the cookie. Anything that stops the browser from storing or sending the cookie, or stops the server from finding the session behind it, produces the same symptom. HTTPS termination at proxies, multiple processes, and cookie attribute rules are the production-only factors that development environments never exercise.

Edge cases / notes

Logged out after password change

Django invalidates sessions when a user's password changes, unless the view calls update_session_auth_hash(). That is expected behavior for other sessions but surprising in the session that changed the password.

Admin works, custom login does not

A custom login view that authenticates but forgets to call login(request, user) never creates the session.

Clock skew

Cookies with Expires in the past, due to a badly wrong server clock, are discarded immediately. Keep servers synchronized with NTP.

Common mistakes

  • Enabling SESSION_COOKIE_SECURE before the site is served over HTTPS.
  • Using LocMemCache for sessions with multiple Gunicorn workers.
  • Setting SESSION_COOKIE_DOMAIN to a domain that does not match the site.
  • Caching the login page at the CDN.

Next steps

FAQ

Why does Django log me out immediately after login in production?

The browser is not sending the session cookie back, or the server cannot find the session. Check for secure cookies on HTTP, cookie domain mismatches, and a non-shared session backend such as local memory cache.

Yes, if the site is served over plain HTTP, because browsers refuse to store or send secure cookies over insecure connections. Serve the site over HTTPS first.

Why do users get logged out randomly with multiple Gunicorn workers?

Sessions are probably stored in LocMemCache, which is separate per process. Use the database, cached_db with Redis, or signed cookie sessions.

Do all servers need the same SECRET_KEY for sessions?

Yes. Session validation depends on SECRET_KEY, so servers in the same environment must share it, or users are logged out when requests move between servers.

Keep going