auth · tutorial

Stale Sessions Bounce You Forever (One-Rule Fix)

Session deleted but cookie alive means bounce loops. The one rule — /auth redirects away only when the user row exists — plus triage. Inside.

A serpent biting its own tail around two birds and a sun-face, an 18th-century Arabic ouroboros drawing
A serpent biting its own tail around two birds and a sun-face, an 18th-century Arabic ouroboros drawing · Photo: Unknown artist, public domain, via Wikimedia Commons

The login page that won't let you log in

A user returns after months. Their cookie survived; their user row didn't (pruned, migrated, deleted in a cleanup).

The app sees a session, looks up the user, finds nothing, redirects to /auth — which sees the session, declares them logged in, and sends them back. Bounce, bounce, bounce, on a page whose only job is logging in.

Or the quieter version: password changed elsewhere, admin revoked access, subscription lapsed — and the stale cookie keeps rendering a dashboard for a user who no longer exists. Access granted to a ghost.

Stale session redirect fix is one invariant: the cookie proposes, the database disposes. No row, no session, no loop — just login.

Stale-session failures and costs

Stale state User sees Cost
Deleted user, live cookie Infinite /auth bounce Login impossible, support ticket
Revoked access, live cookie Dashboard for a ghost Data leak to ex-user
Changed email, old session Wrong identity rendered Actions attributed wrongly
Expired trial, cached flag Paid UI, failing actions Confusion + dispute risk
Cookie flags wrong Random logouts Unreproducible complaints

Three details make staleness loop instead of merely failing:

  1. Both pages trust the cookie alone. /dashboard trusts it (renders, then fails the lookup, redirects), /auth trusts it (sees session, redirects away) — two believers, zero verifiers, infinite bounce. The loop needs both sides fixed: dashboard verifies the row, auth verifies the row.
  2. Cleanup jobs create the ghosts. User pruning, GDPR deletes, test-row purges — all legitimate operations that orphan cookies on devices you'll never touch. Every cleanup multiplies stale cookies; the redirect rule must assume they exist constantly.
  3. Revocations need the same path. Any change that invalidates a session, such as removing a user or an admin revoking access, leaves tokens that no longer match a record. Without the row-check rule, those sessions bounce instead of landing on login, which turns a security feature into a UX bug.

The JWT post kept sessions consistent. This one lands their failures gracefully.

The stale session redirect rule

One invariant in two places: verify the user record everywhere a session is trusted, and render login on absence.

Step 1: Make the sign-in page verify before redirecting away. When someone arrives with a session cookie, look up the user it points to. If the user exists, send them to the dashboard. If not, fall through and show the login form. The fall-through is the whole fix: absence of proof renders the form instead of redirecting. No record, a lookup error or anything odd all lead to login, and none leads back to the dashboard. Loops need two redirectors, and this page refuses to be one of them.

Step 2: Make protected layouts verify identically. BoringPush's paid-access guard already checks that the user exists. A signed-out visitor, or one whose user is gone, is sent to sign in, and the sign-in page now lands. A signed-in visitor without a purchase is sent to pricing. Layouts inherit staleness handling by calling the guard. The pair composes: the layout bounces ghosts to sign-in, and sign-in renders login for ghosts. Each side does its half, and the loop has nowhere to form.

Step 3: Clear the corpse cookie on verified staleness. When a session exists but the user does not, delete the dead cookie, log the event and then render the login form. Destroying it turns repeat visits from "bounce again" into a clean login page. The log line also quantifies the fallout from your cleanup jobs. The device heals itself on first contact.

Which is the point: ghosts get forms, users get dashboards, and nobody bounces.

The staleness drill set

Check Action Pass looks like
Deleted user visits Seed a cookie, delete the user Login form, zero bounces
Normal login Valid session Dashboard, first try
Removed admin visits Remove the email, keep the cookie Login form, not a loop
Loop census Watch for rapid bounce pairs in analytics Zero multi-bounce sessions a week

Drill with crafted cookies, valid in signature but pointing at dead user IDs, every quarter. Five minutes proves the invariant against the exact states cleanup jobs create.

Naming the cost: row verification costs one indexed lookup per guarded navigation, plus the complexity of the destroy-and-log path. Cookie-only checks would be faster and wrong, because they trust what cleanup jobs invalidated. The most expensive login is the one that never completes, and it costs a customer, not a query.

FAQ

Is this different from the JWT loop fix?

They are siblings. The JWT post prevents disagreement with a single writer of session truth.

This post lands the failures gracefully when states legitimately diverge, such as deletes and revokes. Both halves or neither, because loops exploit whichever half you skipped.

Why render login instead of a "session expired" error?

Because an expired-session error demands understanding, while a login form demands one action. Ghosts want the door, not the diagnosis. Reserve error copy for cases that need user decisions.

How do native apps or API clients experience staleness?

As 401s, handled client-side. The redirect rule is a web concern, since machines already speak codes. The guard returns the same code for a missing user, so mobile and API consumers share the semantics.

Should cleanup jobs also clear sessions proactively?

They cannot reach device cookies, because those live on hardware you will never touch. What they can do is log the affected IDs for the corpse-collection path. Visits then clean up the corpses over time.

What about "remember me" versus strict sessions?

Lifetimes can differ, but verification must not. Whatever the lifetime, absence of the user record means the login form. BoringPush uses one long-lived session type and does not ship a separate strict mode.

How do I detect loops in production before users report them?

Count rapid sign-in and dashboard bounce pairs per session in analytics, and alert on any nonzero weekly count. Loops are machine-detectable because humans bounce rhythmically. Zero is the only acceptable number.

Keep reading

+ all the boring stuff (analytics, auth, SQLite ↔ Supabase, full marketplace, webhooks)

The Agent-First Shipping Kit

Command from your phone.

Built so autonomous AI agents build what you want without breaking what's already paying you.

$199

Get BoringPush

One-time payment · Lifetime updates · Unlimited projects.

Keep reading