Stop Logging Out Your Best Users in the Name of Security

A "random logout" bug that only hit the heaviest users. The cause: an overloaded service throwing 401s, and a frontend that logged people out on every 401. Here's the fix, the 401/403/5xx decision table, and the questions to run against your own auth code.

Stick-figure comic: a slow report throwing a 401 catapults a user to the login screen on the left; on the right, the same user stays logged in while the widget shows a scoped error

A client pinged us with a bug that did not look like a bug. Users were getting logged out. Not all of them. Not on any schedule anyone could name. Just, sometimes, mid-task, back to the login screen.

Support had a folder for it. They called it "random logout." Twelve tickets. Every single one from a heavy user.

That last part is the tell. Hold onto it, because it is the whole story.

What was actually happening

When their app loaded a dashboard, it fired off a handful of calls at once. The main one, the auth-checked one, was fine. But the side calls, the analytics widget, the recent-activity feed, the usage report, those all hit a service that was having a rough week. And when that service got overloaded, it did what tired services do. It threw a 401.

Not because the user's session was bad. The session was fine. The token was valid. The service was just too busy to answer properly, so it punted with the closest error code it had lying around.

This happens more than you would think. A 401 is supposed to mean "unauthorized," but in the real world it leaks out of overloaded gateways, misconfigured proxies, expired downstream service credentials, and rate limiters that grabbed the wrong status code. The number on the response does not always mean what the spec says it means. It means whatever the thing that emitted it decided to send under load.

Now here is the part that turned a backend hiccup into a trust problem. Their frontend had one rule for 401s, written a long time ago, probably by someone who has since moved on: if you see a 401, log the user out. Every 401. No questions.

It is a reasonable-sounding rule. 401 means unauthorized. Unauthorized means get out. On a whiteboard, it is airtight.

In production, it meant a slow analytics widget could throw you out of the app while you were in the middle of writing something. One overloaded reporting service, one global error handler, and suddenly your session hygiene is being decided by the least reliable endpoint in your stack.

Why it was always the best users

Remember the tell? Every ticket from a heavy user.

Of course it was. The people getting logged out were the ones who opened the most dashboards, loaded the most reports, and kept the most tabs alive. They were generating the most side calls, so they were rolling the dice the most times. Your most engaged users were the ones most likely to hit the one broken path.

The customers who loved the product most were the ones it kept slapping.

That reframed the whole thing for me. This was not a rare edge case buried in the logs. This was the product punishing loyalty, quietly, at scale, one heavy user at a time. And these are exactly the accounts you cannot afford to annoy: the power users, the champions, the ones whose renewal conversation is happening in six months and whose gut feeling about whether your product is "solid" is being formed right now, one random logout at a time.

Nobody churns over a single logout. People churn over an accumulated sense that a tool is flaky, and they usually cannot point to the exact moment the feeling set in. This was that feeling, being manufactured on a schedule, aimed at the accounts that mattered most.

The fix was not clever

That is kind of the point. The best infrastructure fixes rarely are.

We split 401s into two kinds.

If a 401 came back from the actual auth endpoint, the one whose whole job is to check who you are, fine. That is a real session problem. Try a quiet token refresh first. If the refresh works, the user never knows anything happened. If it fails, then yes, log them out, because now it is genuinely a session that cannot be saved.

If a 401 came back from some non-auth endpoint, an analytics call, a report, anything that is not the identity check, we stopped treating it as a security event at all. We retry it once. If it still fails, we show a small error in that corner of the screen. "Couldn't load this right now." The user stays logged in. They keep their work. The broken widget looks broken, which is honest, instead of the whole app looking broken, which is a lie.

Here is the shape of it in plain terms:

  • A request fails with a 401.
  • Was it the auth or token endpoint? If yes, attempt a silent token refresh. On success, replay the original request and move on. On failure, now you log out, and you tell the user why.
  • Was it any other endpoint? Retry once, ideally after a short backoff. On success, move on. On failure, render a scoped error in the component that failed, and leave the session untouched.

One caveat worth saying out loud, because it bites teams that get excited about auto-retry: only retry requests that are safe to repeat. A GET for a report is safe. A POST that charges a card, sends an email, or submits a form is not, and you do not want a silent retry firing it twice. Reads can retry freely. Writes need an idempotency key or a human-confirmed retry button, never a quiet background replay. That one distinction is what keeps your retry logic from turning a harmless display bug into an actual data bug, which is a much worse day than the one you started with.

That is the entire change. Refresh and retry before you assume the worst. Show the error where the error actually is. Save the hard logout for a real security event.

The "random logout" folder stopped filling up.

The decision table nobody had written

Here is what I realized after the fix shipped. This was never really an auth bug. It was a missing decision.

Nobody had ever sat down and answered a boring question: what should actually happen when a request fails? Not in general. Specifically. On a 401 from auth versus a 401 from a report. On a 403. On a 500. The app had a default, log them out, and a default is what you get when nobody made a choice.

So we wrote the choice down. Something like this, adapted to your own app:

Status From auth/identity endpoint From a normal data endpoint
401 Unauthorized Attempt silent token refresh. If it fails, log out and explain. Retry once. If it still fails, show a scoped error. Do not touch the session.
403 Forbidden Session is valid but lacks permission. Do not log out. Show a clear "you don't have access" message. Same. This is a permissions UX problem, not a logout trigger.
429 Too Many Requests Back off and retry with the retry-after header. Never log out. Same. Surface a gentle "slow down, trying again" state if it persists.
5xx Server Error The server broke, not the user. Retry with backoff, then show an error. Never log out. Same. A 500 is your bug, not their session.
Network / timeout Retry with backoff. Show an offline or retry state. Preserve all in-progress work. Same. Assume the connection, not the credentials.

None of this is exotic. It is the kind of table that takes an afternoon to write and then silently prevents a category of bugs for years. The reason most teams do not have one is not difficulty. It is that nobody was ever assigned to write it, so the framework default became the product's behavior by accident.

A 403 is the sneakiest one, by the way. A lot of apps quietly log users out on a 403 too, which is even worse than the 401 case, because a 403 means the session is completely valid, the user just asked for something they are not allowed to have. Logging them out is like escorting someone out of the building because they knocked on a locked door. Show them the locked door. Let them keep walking around the parts of the building they do have keys to.

Security and UX are not enemies

This is the frame I want to push back on, because it does real damage.

Security and user experience get talked about like a tradeoff, like there is a single slider and every notch you spend on one comes out of the other. On genuinely hard architectural calls, sometimes that is true. Multi-factor prompts, session timeouts, step-up auth for sensitive actions, those involve real friction you are choosing to add on purpose.

But the random-logout bug was not that. Logging a paying customer out of their session because a reporting service was slow did not make a single person safer. It did not close an attack vector. It did not protect any data. It just made the product feel unreliable, and, this is the part that stings, it made a real security response mean less. If your app logs people out constantly for nothing, then the one time it logs someone out for a genuine reason, that signal is worthless. You have trained your users to read "logged out" as "the app is being flaky again," not "something is wrong, pay attention."

That is the actual cost. Not just the annoyance. You are spending down the meaning of your own security signals.

The mature version of this is not "more security" and it is not "better UX." It is rules. Written down and agreed on. When do we retry without bothering the user? When do we show a friendly, localized error? When do we force a logout, and how do we tell the person why in a way that does not feel like a punishment? Those are answerable questions. Most teams have simply never been asked them out loud.

If you have read my piece on how you handle the fire drill, this is the same idea one layer down in the stack. The moment something goes wrong is the moment your product's real character shows. A graceful failure builds more trust than a feature that never gets used.

Why this matters more right now

I think this hits harder this year than it would have a year ago.

Every B2B tool on earth is racing to bolt on another AI feature. Buyers are drowning in it. And the thing I keep hearing from the people who actually approve the invoices is that they are not wowed by one more AI panel. They are tired of software that flakes.

Reliability is the feature that is quietly winning. Dashboards that do not silently fail. Sessions that do not throw away your work because a background call sneezed. A clear, honest message when something breaks, instead of a mysterious boot to the login screen with no explanation. We built a whole client project around exactly this idea, that trust comes from the boring reliability, not the flashy surface, and it has held up better than any feature race would have.

None of that demos well in a sales call. All of it shows up in retention numbers twelve months later. The flashy stuff wins the demo. The boring stuff wins the renewal.

A checklist you can run this week

If you want to find out whether your app has this problem, here is a short audit you can do without a big project or a planning cycle.

  1. Grep for your global error handler. Find every place a 401 triggers a logout. Ask whether it distinguishes the source of the 401 at all. If it treats every 401 the same, you probably have some version of this bug.
  2. Test the flaky-endpoint case on purpose. Point a non-auth call at a URL that returns a 401 and watch what your app does. If it logs you out, you found it.
  3. Do the same for 403 and 500. These should almost never log a user out. If they do, write it down as a bug.
  4. Add a silent refresh path for real auth failures. A single token refresh before logout catches the most common real case, an expired access token, with zero user-visible friction.
  5. Give every data call a scoped error state. A failed widget should look like a failed widget, not a failed app. This is a design task as much as an engineering one.
  6. Write the decision table. Put it in your repo. Make it the reference for every new endpoint. New code inherits the rule instead of inheriting the accident.
  7. Assign an owner. Someone owns "what a failed request feels like to the user." If nobody owns it, the framework default owns it, and the framework does not care about your renewal rate.

That is a week of work for most teams, maybe less. The payoff is a category of trust-eroding bugs that quietly disappears, felt most by the exact users you least want to annoy.

The part I keep coming back to

The people who were getting logged out never filed a bug that said "your 401 handling is too aggressive." They could not have. They did not know what a 401 was. They just knew the app kept kicking them out, and they slowly, quietly, started to trust it a little less.

That is how reliability problems actually work. They do not arrive as a clean ticket. They arrive as a vibe, an accumulated sense that a tool is not quite solid, forming in the heads of your best customers while your roadmap is busy with something shinier.

So go ask the boring questions. What happens in our app on a 401, a 403, a 500? Not what should happen. What happens right now, today, in the code that is running in production. And who owns that answer.

If nobody can answer either one, you are in good company, because most teams can't. But you just found your next high-impact, low-glamour piece of work. The unglamorous kind your heaviest users will feel immediately, even if they never write a ticket to say thanks.


This is the kind of quiet reliability work my team at Chykalophia does for SaaS and B2B products, the auth flows, error states, and session logic that never make the feature list but decide whether your best users stay. If your app has a "random logout" folder of its own, let's talk. We are happy to run the audit above with you.

Great! Next, complete checkout for full access to Piotr Krzyzek.
Welcome back! You've successfully signed in.
You've successfully subscribed to Piotr Krzyzek.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info has been updated.
Your billing was not updated.