DEV Community

Lorraine Moyo
Lorraine Moyo

Posted on

A Simple Login Bug That Touched Four Layers of the Stack

I recently spent several hours debugging what appeared to be a straightforward authentication issue while building a new application.

The symptom was simple:

  • Enter an email address
  • Click Sign In
  • Receive a valid JWT
  • Get redirected
  • Immediately end up back on the Sign In page

At first glance, this looked like a classic authentication bug.

It wasn't.

What made this issue interesting was that it touched four different layers of the stack:

  1. FastAPI JWT generation
  2. Browser storage
  3. React Context
  4. Route protection

And the surprising part?

All four layers were working correctly.

The failure was in the communication between them.

The Architecture

I was building a simple authentication flow using:

  • FastAPI backend
  • React frontend
  • JWT authentication
  • React Context for authentication state

The intended flow looked like this:

User enters email
        ↓
POST /auth/token
        ↓
Backend returns JWT
        ↓
Store token in localStorage
        ↓
Load current user
        ↓
Update AuthContext
        ↓
Navigate to protected routes
Enter fullscreen mode Exit fullscreen mode

Simple enough.

Or so I thought.

Layer 1: FastAPI JWT Generation

My first suspicion was that the backend wasn't generating valid tokens.

Initially, I was experimenting with Supabase-compatible JWTs and encountered audience validation issues.

The token contained claims like:

{
  "email": "test@example.com",
  "aud": "authenticated",
  "iss": "..."
}
Enter fullscreen mode Exit fullscreen mode

PyJWT was rejecting the token during validation because the audience wasn't configured correctly.

After tracing the issue, I simplified the authentication model entirely.

Instead of trying to emulate Supabase Auth, I switched to a custom JWT approach:

payload = {
    "sub": email,
    "exp": datetime.utcnow() + timedelta(hours=24)
}
Enter fullscreen mode Exit fullscreen mode

The backend generated tokens correctly.

The /me endpoint validated them correctly.

I could manually call protected endpoints using curl and receive valid responses.

Backend authentication was working.

Layer 2: Browser Storage

Next, I investigated the frontend.

The sign-in flow looked like this:

const { access_token } = await response.json()
localStorage.setItem('auth_token', access_token)
Enter fullscreen mode Exit fullscreen mode

After signing in, I opened DevTools and checked localStorage.

The token was there.

To be absolutely sure, I copied the token and manually called the backend:

curl /me
Enter fullscreen mode Exit fullscreen mode

The request succeeded.

The token was valid.

Storage was working.

Layer 3: Route Protection

At this point, the bug became more confusing.

The frontend was still redirecting me back to the Sign In page.

I inspected the route protection logic.

The code was essentially:

if (!isAuthenticated) {
  return <Navigate to="/signin" />
}
Enter fullscreen mode Exit fullscreen mode

The route guard wasn't broken.

It was behaving exactly as it had been instructed.

It genuinely believed the user was unauthenticated.

The question became:

Why?

Layer 4: React Context

This was where the real issue lived.

My application used an AuthProvider to track authentication state.

When the application mounted, it executed something similar to:

useEffect(() => {
  checkAuth()
}, [])
Enter fullscreen mode Exit fullscreen mode

The logic seemed reasonable.

On startup:

  1. Check if a token exists
  2. Load the current user
  3. Update authentication state

The problem was timing.

When the AuthProvider first mounted, there was no token in localStorage.

So the provider correctly concluded:

isAuthenticated = false
Enter fullscreen mode Exit fullscreen mode

The user then signed in.

The token was stored.

But the AuthProvider never checked again.

The authentication state had already been calculated.

The provider's understanding of reality was now stale.

The Discovery

This was the key realization:

The login succeeded.

The token existed.

The backend trusted the token.

The browser stored the token.

The route guard worked.

The application state simply didn't know any of that had happened.

The system wasn't failing because authentication was broken.

It was failing because authentication state wasn't synchronized.

The Fix

The solution was not to modify JWT generation.

It was not to modify route protection.

It was not to modify localStorage.

Instead, I exposed a refreshAuth() function through the AuthContext.

After a successful login:

await signInWithEmail(email)
await refreshAuth()
navigate('/')
Enter fullscreen mode Exit fullscreen mode

The refresh function:

  1. Re-read the token
  2. Called /me
  3. Loaded the current user
  4. Updated the AuthContext

Now the route guard received fresh authentication state.

Everything worked.

The Lesson

This bug reminded me of an important engineering principle:

When debugging modern applications, the component that appears broken is often not the component causing the failure.

The symptoms pointed toward authentication.

The backend looked suspicious.

The JWT looked suspicious.

The route guard looked suspicious.

None of them were actually broken.

Every individual piece was functioning correctly.

The failure existed in the communication between those pieces.

As systems become more distributed—even within a single application—the most interesting bugs often emerge not from incorrect logic, but from valid logic operating on outdated information.

The login worked.

The state didn't.

And that distinction made all the difference.

Top comments (0)