Back to Feed
article 3m read

Login loop fixed twice in the wrong layer

The new sliding-session renewal re-signed a decoded token, the signer threw, and the guard turned that into a 401 for good tokens. I patched the middleware's refresh loop twice before moving renewal out of the way of login.

Article DepthPractice

Flow

From renewal to the guard repair

  1. 1Renewal added

    Each good request signs the decoded token again.

  2. 2Good tokens rejected

    Signing throws, and the guard's catch turns it into a 401.

  3. 3Middleware fix one

    The refresh call returns the new token so the cookie can be set directly.

  4. 4Middleware fix two

    Retry with the new token in the same request and stamp every response.

  5. 5Guard repair

    Sign identity fields only, and let a failed renewal log a warning instead of blocking.

Less than an hour after I added sliding sessions to my scheduling app, I was fixing a redirect loop. Then fixing it again. Then adding debug logging. The real cause was one line I'd added that same day, inside a try block that caught too much.

My app keeps you signed in with two cookies. A short one proves who you are on each request. A long one can get you a new short one when it runs out. A guard in the API checks the short one. In front of the pages, a middleware layer asks the API who you are on every page load.

A sliding session means each good request gets a fresh short cookie with a new expiry. You stay signed in while you keep working. I put that renewal in the guard, right after the token check. It shipped in a commit about adding staff without email, which is the wrong place for a login change.

Re-signing a token that still carried its own expiry

The renewal took the checked user and signed it again. But that user object was the decoded old token. It still had the old expiry and issuer on it. The signer wants to set those itself, and my JWT library throws rather than choose.

The throw landed in the same catch as a bad token, so a good token got a 401. My reading is that this hit every cookie request, since every decoded token has an expiry. I don't have the error from that day, so that part is my best guess from the code.

A refresh that worked and a guard that didn't

As best I can tell, the middleware saw the 401, used the long cookie to get a new short one, and sent the browser back to the same page. The next page load hit the broken guard again. Round and round.

I fixed the middleware twice before I touched the guard. First, the refresh call returned the new token so the middleware could set the cookie itself. Second, the middleware retried right away with the new token instead of redirecting, and put the new cookie on every response. Both were good changes. Neither could work while the guard rejected every good token. And the second one still fell back to the old redirect when its retry failed, so I'd bet it kept looping.

Renewal gets its own try block

Renewal now signs only the identity fields, and the signer handles the timing. It also has its own try block, so a failed renewal just logs a warning and the request goes through on the token that was already good.

try { renewCookie(res, pickIdentity(who)); }
catch (err) { log.warn("renewal skipped", err.message); }
return true;

If you build one of these, test it with a signer that throws on purpose, and make sure the page still loads. None of that day's commits had a test like that. Mine also has the cookie lifetime written in several places, which I'll trip over sooner or later.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward