DEV Community

Cover image for The Login Loop of Doom.
George Benjamin
George Benjamin

Posted on

The Login Loop of Doom.

Summer Bug Smash: Smash Stories 🐛🛹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry. Code snippets are recreated and anonymized for illustrative purposes.

The Symptom: A Revolving Door Instead of a Login Page

It started innocently enough: I was clicking through our app and hit "Log in." Auth0's Universal Login page appeared, I entered my credentials, got redirected back to the app... and landed on the Auth0 login page again. And again. And again.

No error message. No failed login attempt. Auth0 was happily authenticating me every single time — and our app was just as happily bouncing me right back, like a bouncer who checks your ID, nods, and then immediately forgets he checked it.

The login loop. Every developer's favorite horror movie, now starring me.

Red Herring #1: "It's the Frontend's Fault"

My first suspect was the obvious one: the frontend callback handler. A Node.js/Express app sits in front of our Django API, handling the Auth0 redirect dance. A login loop screams "broken callback" or "state/nonce mismatch," so I spent a solid hour there:

  • ✅ State parameter matched
  • ✅ Nonce validated
  • ✅ Callback URL whitelisted in the Auth0 dashboard
  • ✅ ID token and access token both present in the response

Everything the frontend touched was perfect. The tokens were real, signed by Auth0, freshly issued seconds ago. And yet the moment the frontend sent the access token to our Django API, the API answered with a flat 401 Unauthorized.

Fine. New suspect.

Red Herring #2: "Auth0 Must Be Misconfigured"

Next stop: the Auth0 dashboard. Maybe the token lifetime was set to something absurd, like 5 seconds? Maybe the audience claim was wrong?

  • Token lifetime: 3600 seconds. Normal.
  • aud claim: matched our API identifier exactly.
  • Signature: verified against the JWKS. Valid.

So Auth0 was issuing perfectly good tokens, the frontend was delivering them intact, and Django was spitting them out. The bug had to be in the validation logic itself. Time to actually read the code we trusted blindly every day.

The Root Cause: Two Clocks, One Lie

Buried in our custom JWT validation middleware, I found this:

# The offending code (recreated)
from datetime import datetime

def validate_token(payload):
    # ...
    exp = payload.get("exp")
    if exp is None:
        raise InvalidTokenError("Missing exp claim")

    if datetime.now().timestamp() > exp:
        raise TokenExpiredError("Token has expired")
Enter fullscreen mode Exit fullscreen mode

Looks harmless, right? That's exactly why it survived code review.

Here's the problem: JWT exp is a Unix timestamp — seconds since epoch, UTC. datetime.now() returns local server time. On a machine configured for UTC, the comparison works by pure luck. But our server wasn't on UTC. It was several hours ahead.

So the moment Auth0 issued a token, our server looked at its own clock — hours in the future — and declared the token already expired. Every token. Instantly. Forever.

The sequence of doom:

  1. User logs in via Auth0 → valid token issued with exp = now_utc + 3600
  2. Frontend calls Django API with the token
  3. Django compares exp against local time, sees it as "expired" → 401
  4. Frontend's interceptor sees 401 → "session must be dead" → redirects to Auth0
  5. Auth0 session cookie is still valid → silently issues a brand new token
  6. Go to step 2. Repeat until the user gives up.

A timezone bug, wearing an authentication bug's clothes.

The Fix: One Line and a Lesson

# The fix (recreated)
from datetime import datetime, timezone

def validate_token(payload):
    exp = payload.get("exp")
    if exp is None:
        raise InvalidTokenError("Missing exp claim")

    if datetime.now(timezone.utc).timestamp() > exp:
        raise TokenExpiredError("Token has expired")
Enter fullscreen mode Exit fullscreen mode

Actually, the honest fix was two lines, because we also hardened the failure mode on the frontend: the interceptor now distinguishes "token rejected" from "session expired" instead of blindly redirecting to login on every 401. A 401 from token validation should never automatically mean "start the whole login dance over" — that assumption is what turned a quiet bug into a user-facing infinite loop.

The Verification: Become Your Own Attackers

Debugging this took a few hours; verifying the fix took a different mindset. I didn't just log in as myself and call it a day — I ran the flow with multiple different accounts through different login paths: social login vs. email/password, fresh accounts vs. long-lived ones, incognito sessions vs. sessions with existing Auth0 cookies. Every combination, every timezone simulation (I also changed the server TZ deliberately to break it again on purpose — highly recommended; a fix you can't re-break is a fix you don't understand).

All green. The revolving door became a door again.

What I Took Away

  1. datetime.now() without a timezone is a loaded gun. In Python, always use datetime.now(timezone.utc) for anything that touches timestamps, tokens, or comparisons across systems. Better yet, lint for it.
  2. The scariest bugs live at system boundaries. Auth0 was fine. The frontend was fine. Django was "fine." The bug existed only in the assumption that two machines share a clock.
  3. A 401 is not a synonym for "please log in again." Distinguish your failure modes, or your error handling will amplify small bugs into infinite loops.
  4. Test like an attacker, not like yourself. Different accounts, different login methods, different session states. The bug hid from the happy path and lived everywhere else.

The best part? This class of bug is everywhere. If your stack validates JWTs anywhere by hand, go grep for naive now() calls right now. I'll wait. 🐛🔨

Top comments (0)