What this usually means
Django's authentication system relies on a chain of components: the session middleware, the authentication middleware, and the authentication backends. When login 'fails silently' (redirect back to login), it's almost always because the authenticate() call returned None — meaning no backend validated the credentials. But there's a second class of failures: the user is authenticated but the session doesn't stick (cookie domain mismatch, CSRF token rotation, or SESSION_COOKIE_SECURE misconfiguration) or the request.user is AnonymousUser despite having a valid session (auth middleware not in MIDDLEWARE or wrong order). In production, proxy headers (X-Forwarded-For, X-Forwarded-Proto) can break SECURE_PROXY_SSL_HEADER and cause session cookies to be set over HTTP, then rejected by the browser for HTTPS.
The first ten minutes — establish facts before touching code.
- 1Check if the user object is actually being authenticated: add print(authenticate(username=..., password=...)) in your login view and see if it returns a user or None.
- 2Verify the session cookie: open browser DevTools → Application → Cookies. Look for sessionid (or your custom SESSION_COOKIE_NAME). Check domain, path, Secure, SameSite flags.
- 3Test with Django's built-in test client: from django.test import Client; c = Client(); response = c.post('/login/', {'username': 'test', 'password': 'test'}); print(response.cookies).
- 4Inspect the AUTHENTICATION_BACKENDS setting: ensure it includes 'django.contrib.auth.backends.ModelBackend' or your custom backend.
- 5Check MIDDLEWARE ordering: 'django.contrib.sessions.middleware.SessionMiddleware' must come before 'django.contrib.auth.middleware.AuthenticationMiddleware'.
- 6If using HTTPS: ensure SESSION_COOKIE_SECURE=True and CSRF_COOKIE_SECURE=True, and SECURE_PROXY_SSL_HEADER is set correctly if behind a proxy.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchsettings.py: AUTHENTICATION_BACKENDS, MIDDLEWARE, SESSION_COOKIE_*, CSRF_COOKIE_*, SECURE_PROXY_SSL_HEADER
- searchlogin view code: check authenticate() and login() calls, redirect logic
- searchDatabase table auth_user: run SELECT * FROM auth_user WHERE is_active=1; to confirm user exists
- searchBrowser DevTools → Network: inspect login POST request and response headers (Set-Cookie)
- searchDjango logs (if configured): DEBUG-level logging for django.request and django.auth
- searchNginx/Apache logs: check for proxy headers and SSL termination settings
- searchRedis/Memcached (if using cached sessions): check session key existence and expiration
Practical causes, not theory. These are the things you will actually find.
- warningAUTHENTICATION_BACKENDS missing 'django.contrib.auth.backends.ModelBackend'
- warningSessionMiddleware or AuthenticationMiddleware missing or in wrong order in MIDDLEWARE
- warningSESSION_COOKIE_DOMAIN set incorrectly (e.g., 'example.com' vs '.example.com')
- warningCSRF cookie not set because CSRF_USE_SESSIONS=True and session not saved
- warningSECURE_PROXY_SSL_HEADER misconfigured — Django thinks request is HTTP, sets Secure=False cookie, browser rejects on HTTPS
- warningUser.is_active=False (user disabled) — authenticate() returns None
- warningCustom UserModel but AUTH_USER_MODEL not set correctly or migrations not run
Concrete fix directions. Pick the one that matches your root cause.
- buildSet AUTHENTICATION_BACKENDS = ['django.contrib.auth.backends.ModelBackend'] explicitly
- buildRe-order MIDDLEWARE: ensure SessionMiddleware first, then AuthenticationMiddleware, then any custom auth middleware
- buildAlign SESSION_COOKIE_DOMAIN: use None for same-domain, or '.yourdomain.com' for subdomain-wide
- buildConfigure SECURE_PROXY_SSL_HEADER: SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
- buildSet SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE to True in production
- buildEnable SESSION_SAVE_EVERY_REQUEST = True if sessions expire too fast
- buildUse django.contrib.sessions.backends.cache with proper cache backend for faster session reads
A fix you cannot prove is a guess. Close the loop.
- verifiedLog in and immediately check response cookies — you should see sessionid with Secure flag (if HTTPS) and correct domain
- verifiedOpen a new incognito window, log in, close it, reopen — session should persist if SESSION_EXPIRE_AT_BROWSER_CLOSE=False
- verifiedRun manage.py check --deploy and ensure no warnings about session/csrf security settings
- verifiedWrite a unit test: self.client.post('/login/', ...); self.assertTrue('sessionid' in self.client.cookies)
- verifiedUse curl to simulate login: curl -c cookies.txt -b cookies.txt -X POST -d 'username=...&password=...' https://yoursite.com/login/ and inspect cookies.txt
- verifiedCheck auth_user.last_login updates after successful login
Things that make this bug worse or harder to find.
- warningDo not set SESSION_COOKIE_SECURE=False in production — you'll get intermittent login failures on HTTPS pages
- warningDo not hardcode LOGIN_URL or LOGIN_REDIRECT_URL to a path that itself requires authentication
- warningDo not modify the session dictionary after login() without calling request.session.save()
- warningDo not forget to call login(request, user) after authenticate() — authenticate only validates, login creates the session
- warningDo not set SESSION_EXPIRE_AT_BROWSER_CLOSE=True unless you really want session to end on browser close
- warningDo not ignore the CSRF token — if you're using Django forms, always include {% csrf_token %}
The OAuth2 Login Loop That Cost Us a Deployment
Timeline
- 09:15Deploy new OAuth2 integration with Google. Login redirects to Google, user approves, redirected back to /accounts/google/login/callback/.
- 09:18Callback URL returns 302 redirect to /accounts/profile/ but user ends up on login page again (AnonymousUser).
- 09:25Check Django logs: allauth signals user_logged_in fired. user is not None.
- 09:30Add debug print in custom profile view: print(request.user, request.session.session_key). Shows AnonymousUser and no session key.
- 09:40Check browser cookies: no sessionid cookie. Check response headers: Set-Cookie is present but Secure flag missing.
- 09:45Realize the ELB terminates SSL and forwards HTTP to Gunicorn. SECURE_PROXY_SSL_HEADER not set.
- 09:50Set SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') and SESSION_COOKIE_SECURE=True.
- 09:55Redeploy. Login now works. Session cookie has Secure flag. User authenticated.
We had just rolled out Google OAuth2 login using django-allauth. In staging, it worked fine. But in production, users reported being stuck in a login loop — they'd approve the Google consent screen, get redirected back to our app, and then see the login page again. No error message, just a redirect back to /accounts/login/. I started by checking the allauth logs. The user_logged_in signal was firing, which meant the user was authenticated by allauth's backend. But somehow, the session wasn't persisting.
I added a temporary print in the profile view that users were redirected to: print(f'User: {request.user}, Session key: {request.session.session_key}'). Every request showed AnonymousUser and None for the session key. That meant no session cookie was being sent by the browser. I opened DevTools and saw that the login callback response did include a Set-Cookie header for sessionid, but it was missing the Secure flag. Our site is HTTPS-only, so the browser silently dropped the cookie.
The root cause was that our AWS ELB terminates SSL and forwards HTTP to the Gunicorn instances. Django, receiving an HTTP request, set the session cookie without the Secure flag. The fix was to add SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') in settings.py and set SESSION_COOKIE_SECURE=True. After redeploy, the cookie had Secure=True, the browser accepted it, and the login loop was gone. We also added CSRF_COOKIE_SECURE=True while we were at it.
Root cause
Missing SECURE_PROXY_SSL_HEADER configuration behind an SSL-terminating load balancer caused Django to set session cookies without the Secure flag, which browsers reject on HTTPS pages.
The fix
Add SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') and set SESSION_COOKIE_SECURE=True in production settings.
The lesson
Always test authentication flows behind your actual proxy infrastructure. Staging often lacks the same proxy headers, so a working setup there doesn't guarantee production behavior.
Django's auth system has three layers: the authentication backends, the authentication middleware, and the session middleware. When a login request comes in, your view typically calls authenticate() which iterates over AUTHENTICATION_BACKENDS until one returns a user object (or None). If authenticate() returns a user, you then call login(request, user) which creates a session, attaches the user ID to the session, and sends a session cookie to the client.
On subsequent requests, the SessionMiddleware reads the session cookie and loads the session data. Then AuthenticationMiddleware retrieves the user ID from the session and attaches request.user. If any of these steps fail — cookie not sent, session not found, user ID missing — request.user becomes AnonymousUser. The most common silent failure is the cookie being rejected by the browser due to missing Secure flag, wrong domain, or SameSite=Lax issues.
When authenticate() returns None, Django's default ModelBackend checks the user's password against the stored hash and also checks is_active. If the user doesn't exist or the password is wrong, it returns None silently. To debug, you can temporarily set AUTHENTICATION_BACKENDS = ['django.contrib.auth.backends.ModelBackend'] and add logging: from django.contrib.auth import get_user_model; User = get_user_model(); user = User.objects.filter(username=username).first(); print(user, user.check_password(password)) if user else print('User not found').
If you're using a custom backend (e.g., email-based login), ensure it's in AUTHENTICATION_BACKENDS and that it returns a user object, not None. Also check that the backend's get_user method is implemented correctly — it's called on every request to retrieve the user from the session's user ID.
The MIDDLEWARE list order matters. SessionMiddleware must appear before AuthenticationMiddleware because AuthenticationMiddleware needs the session to be loaded. If they're reversed, request.session will be empty and request.user will be AnonymousUser. A typical correct order: SessionMiddleware, CommonMiddleware, CsrfViewMiddleware, AuthenticationMiddleware, MessageMiddleware, SecurityMiddleware (if using django.middleware.security.SecurityMiddleware).
If you have custom middleware that modifies request.user or the session, ensure it comes after AuthenticationMiddleware. Also, if you're using LocaleMiddleware, it should be after SessionMiddleware but before AuthenticationMiddleware to avoid session language issues.
Frequently asked questions
Why does login work on one page but not another?
Check if the failing page is accessed via a different domain or subdomain. Your SESSION_COOKIE_DOMAIN might be set to a specific domain, and cookies aren't shared. Also, if the page uses HTTPS but login was done over HTTP (or vice versa), the Secure flag mismatch will cause the cookie to be omitted. Finally, ensure the CSRF token is included in forms on all pages.
How do I fix 'CSRF token missing or incorrect' after login?
This often happens when CSRF_USE_SESSIONS=True and the session is not saved after login. Call request.session.save() after login() explicitly. Alternatively, ensure CSRF_COOKIE_SECURE and CSRF_COOKIE_HTTPONLY match your session cookie settings. Also check that you're not rotating the CSRF token on each request (CSRF_COOKIE_AGE).
My login works locally but not on Heroku / AWS / Docker. Why?
Almost always a proxy/load balancer issue. Your hosting platform terminates SSL and forwards HTTP to your app container. Django needs SECURE_PROXY_SSL_HEADER to know the original scheme. Check your platform's documentation for the correct header. Also ensure SESSION_COOKIE_SECURE=True and that your app is not behind a CDN that caches cookies.
What causes 'Session data corrupted' or 'Invalid session key' errors?
This typically means the session backend (database, cache, file) is misconfigured or the session data has been tampered with. If using database sessions, run python manage.py clearsessions to remove expired sessions. If using cache, check that the cache server is running and the timeout settings are consistent. Also ensure SESSION_SERIALIZER is set correctly (default is JSONSerializer).
How do I debug authentication with a custom User model?
Ensure AUTH_USER_MODEL is set correctly in settings.py before any migrations. If you change it after migrations, you'll need to reset the database. In your custom backend, use get_user_model() to reference the user model. For login views, use Django's built-in authentication forms (AuthenticationForm) which automatically handle custom user models. Also check that the USERNAME_FIELD and REQUIRED_FIELDS on your model match your login form.