I thought I was debugging a WeChat bug.
The symptom looked specific enough to be boring.
A signed-in user tapped the account-link button, the app handed off to WeChat, and about five and a half seconds later the link attempt failed.
In the same release cycle, I was also hearing that some users were getting signed out more often than they should.
At first those looked like separate problems.
One was a China-specific provider flow.
The other was generic session reliability.
They were actually the same bug chain.
That chain mattered because the visible screen was lying about where the real fault lived.
The social-login failure was only the last stage of a broken session lifecycle.
Wimemo is an iPhone app for private travel memory. Photos stay on device by default. Accounts exist to preserve metadata across reinstalls, support collaboration, and attach explicit cloud behavior to the moments where it is actually needed. That means auth is not a cosmetic layer. If session state becomes unreliable, recovery, sharing, and provider linking all start to look flaky even when their own screens are fine.
The production evidence for this incident was surprisingly concrete.
The exact live builds were China 1.0.41 (90) and global 1.0.41 (93) at source SHA 190b3dd0. Anonymous production telemetry showed one already signed-in install firing auth.oauth_link_requested, then turning into auth.oauth_link_failed about 5.6 seconds later. The event still carried is_signed_in = true, which meant the app had not lost all local auth state yet. Two other installations showed six WeChat login failures and then a fallback to OTP instead of a successful provider completion.
That was enough to kill the simplest theory.
If the app had never reached the provider flow at all, the timing would have been different and the fallback pattern would have looked different. If the WeChat SDK or Universal Link wiring were broken at the outer layer, the request would not have made it as far as it did.
The backend evidence pushed the same direction. The production wechat-login endpoint was configured well enough to reject an empty code with 400 code required and a diagnostic invalid code with 401 wechat_exchange_failed. The iOS release still had the right AppID, the right Universal Link host, and a live AASA file. And the current WeChat profile set in production had both wechat_unionid and wechat_openid, so the old historical fear about an openid-only lookup gap was not the main explanation for this specific wave.
Debug The Chain, Not The Screen
The important engineering move was to stop debugging the provider screen and debug the auth chain.
Once I did that, the real failure became obvious.
Refresh tokens rotate.
That is normal and healthy. But a rotating token only helps if the new token is durably persisted before the old one becomes the only copy left on disk.
In the affected iOS code path, session persistence around refresh was not atomic enough and not observable enough. A successful refresh could leave the new session only in memory. If persistence failed, the next cold start would read the older refresh token from Keychain. When that stale token hit the backend, the backend could correctly reply that the refresh token had already been used.
My app translated that answer too aggressively.
Instead of treating “rotated token replayed” as a clue that local persistence had failed, it treated it as definitive session expiry and signed the user out.
That is the moment where two bug reports collapse into one.
The WeChat link flow depended on the same session that refresh had quietly weakened. So a user could experience “WeChat linking is broken” even though the provider itself was alive. The request was entering the backend with a session that looked locally signed in but was already inconsistent enough to fail once it needed an authenticated server step.
Session storage is not a side utility for auth. It is part of the auth protocol.
If the write is not atomic, the protocol is not reliable.
If the failure is not observable, the protocol is not debuggable.
Telemetry Was Hiding The System Boundary
I had also let the telemetry stay too coarse for too long.
The old event stream mostly recorded that a BackendAuthError happened. It did not carry a structured sign-out reason, a refresh stage, or enough privacy-safe session-storage state to tell me whether the user had actively signed out, whether a refresh had been rejected, whether Keychain had failed, or whether a provider-link request had died at a specific backend stage.
That omission is common in small products because auth feels like solved plumbing until the week it stops being solved.
But auth bugs are rarely just “login bugs.” They are chain-of-custody bugs.
You need to know:
- which credential version the client believed it had
- whether the new credential was durably persisted
- which server stage rejected the request
- whether the product interpreted the rejection as revocation, replay, timeout, or conflict
Without that structure, one broken chain presents as five unrelated screens.
The Fix Started With Storage Discipline
The iOS fix started with storage discipline.
The session write path was changed so replacement became atomic from the caller’s perspective. Instead of deleting the old Keychain item and hoping the add succeeded, the code now updates in place where possible, falls back to add only when the item is genuinely missing, and retries duplicate races without deleting either version first. The new tests inject failure into the Keychain path on purpose because “round trip works when nothing goes wrong” is not an auth gate.
That change sounds small. It is not.
Auth state lives longest in the corners that do not throw obvious UI errors: background refresh, cold launch, provider handoff, regional network slowness, and stale process memory. Atomic persistence is what keeps those corners from drifting apart.
The second iOS fix was semantic.
I split “refresh rejected” from “refresh token already used.”
A revoked session should clear auth.
A replayed rotated token might mean the server is right and the client write path is wrong.
Those are not the same product event. Treating them as the same event erases the only clue you needed.
I also forced a durable pre-WeChat refresh step and added structured, privacy-safe diagnostics around sign-out reason and auth stage so the next failure would tell me more than “something went wrong.”
The Backend Had To Tighten The Same Invariant
The backend fix was less about one magic conditional and more about tightening invariants around the same chain.
The provider resolution path now prefers unionid but safely falls back to openid, handles concurrent link or merge attempts idempotently, and emits structured stage and error-code signals so an auth failure can be classified without exposing private provider data. On top of that, I hardened the deployment timeout path because cross-region auth fixes do not help much if the region that needs the provider most cannot receive the new function reliably.
One thing I want to be honest about is that the engineering story did not end the moment the patches merged.
The exact-main gates passed: 961/961 iOS tests, backend migrations and RLS, 314 function tests, and 19 AI eval tests. The global function deploy then exposed a concrete missing WECHAT_APP_ID/SECRET configuration signal. Meanwhile the China deployment host was timing out on both 80 and 443, which meant the new backend function still had to clear an operational delivery problem before the full two-region story could be called finished.
That is frustrating.
It is also useful.
A better system does not merely “fix the bug.” It turns an opaque failure into a specific remaining blocker.
Before this work, the outcome was “WeChat linking failed.”
After this work, the remaining blockers became concrete and classifiable:
- local session replay caused by failed durable refresh writes
- provider-stage errors that needed structured reporting
- a region-specific deployment path that could still fail independently of the code fix
That is real progress even before every external dependency is clean again.
The Reusable Model
Another developer does not need my exact provider stack to reuse the model here.
The model is:
- Debug auth as a chain, not as a screen.
- Treat session persistence as part of the protocol, not as a helper.
- Separate replayed rotated credentials from genuinely revoked credentials.
- Instrument auth by stage and sign-out reason, not only by surface error text.
- Verify provider wiring independently so you do not blame SDK configuration for a storage bug.
- Keep regional deployment and credential configuration in the same reliability conversation, because users experience the whole chain, not your repo boundaries.
There are still limits.
Telemetry around auth should stay privacy-safe, which means the evidence can guide debugging without storing raw identifiers or credentials. Provider ecosystems also change underneath you, so auth reliability never stays “done” for very long. And in a two-region product, the clean code fix and the clean operational release are separate kinds of truth that both matter.
But the thesis I am keeping is durable:
When a provider-specific auth bug and a generic sign-out bug appear in the same release, assume they might share one broken session chain before you assume you have two unrelated bugs.
That is the kind of mistake a small team can afford to stop making only once.
I write these from building Wimemo, a private travel memory app. If you want the product context behind the engineering decisions, you can read more about Wimemo here.