Users kept getting logged out. That was the consistent, top-of-mind complaint about a hybrid iOS app I worked on. Open the app, browse for a few minutes, try to do something, and find yourself staring at the login screen again. The reviews were brutal, and the pattern was real.

Fixing it took the better part of a quarter. Here’s why.

Auth in a hybrid app is a coordination problem

The app was a native shell hosting most of the product in a WKWebView. That meant “logged in” was not one fact, it was several: an OAuth token bundle in the Keychain, a session cookie in the WebView’s cookie jar, an Authorization header on the GraphQL client, a user ID in the push subscriber, the same user ID tagging analytics events, and the UI itself, which had to know whether to show “Sign in” or “Hi, Marvin.”

Each of these was a separate component, persisted in a separate place, refreshed by a separate code path. Any one going stale relative to the others looked, to the user, like “I just got logged out.” A token that expired between cold starts; a cookie left behind after a partial logout; a refresh that failed mid-flight. The bug wasn’t one bug. It was state spread out and not policed.

The shift: one observable source of truth

The rewrite started by collapsing the model. A single authentication state, published reactively. Every consumer that needed to know whether the user was logged in subscribed to it instead of holding its own copy.

The rule was strict: nobody else stored auth state. The WebView didn’t. The GraphQL client didn’t. The push subscriber didn’t. Each of them reconciled itself whenever the state changed.

That sounds obvious in retrospect. It is obvious. It just hadn’t been done.

Layers, not piles

Underneath the published state, the auth flow split into three roles:

  • An orchestrator that owned the state, decided when to refresh, and was the only thing the rest of the app subscribed to.
  • An OAuth adapter that wrapped the underlying OAuth/OIDC library so the orchestrator never had to know about discovery, token exchange, or the library’s callback model.
  • A persistence layer backed by the Keychain, scoped per region (the app shipped in several countries, each with its own issuer).

The discipline was that each layer only knew the one below it. The orchestrator didn’t import the OAuth library. The OAuth adapter didn’t know about Keychain. The persistence layer didn’t know what credentials were for. That meant the public surface the rest of the app subscribed to was tiny and stable, even as the OAuth code underneath was rewritten more than once.

Refresh, without the user seeing it

Token refresh is the easy bit to do. The hard bit is doing it without users noticing. The orchestrator reacted to three things at once: the current auth state, network reachability, and whether the app was in the foreground. Whenever any of them changed, the refresh schedule was recomputed. Offline? Pause. Backgrounded? Pause. Online and visible? Schedule the next refresh well before the current token expires, so a 401 from the server becomes a real signal of something wrong, not the inevitable consequence of a passive expiration.

The mid-refresh moment was hidden from subscribers. While the orchestrator was fetching new credentials, the published state didn’t flicker through “unauthenticated”; it simply didn’t update until the refresh finished. Downstream consumers saw a continuous, sensible state and went on with their lives.

What it bought

After it shipped, the “logged out” review category went quiet.