Blog
Diagram showing a cross-site cookie request between mytreda.com and a Railway subdomain being blocked by Safari ITP
mobileauthcookiesreact-hook-formsafarimytreda

The Bug Report Said "Refresh Logs Me Out." It Was Actually Two Bugs

July 20, 20266 min read

A trader using MyTreda on her phone sent a support message that read, roughly: "when I refresh the app it logs me out, and when I try to log back in it says email and password are required, but I can see them filled in."

That second sentence is the interesting one. "I can see them filled in, but it says required." That's not a vague complaint, that's a precise symptom, and precise symptoms usually point straight at the bug if you don't stop investigating too early.

Symptom 1: refresh logs you out on mobile, not on laptop

MyTreda's auth is set up the way most people would tell you to set it up:

  • Access token lives in memory only, never in localStorage. An XSS payload that can read localStorage can't steal it.
  • Refresh token lives in an httpOnly cookie the browser holds and JS never touches.
  • On boot, if there's no access token in memory (which is always true after a refresh, memory doesn't survive a reload), the client silently calls POST /auth/refresh, the browser attaches the cookie automatically, and a new access token comes back.

On a laptop this worked every time. On a phone it worked... sometimes. That inconsistency was the first real clue, and it's the kind of clue that's easy to wave away as "mobile networks are flaky" which is exactly the wrong instinct.

The actual difference wasn't the network. It was the browser engine, and where the two apps live.

The web app is on Vercel at mytreda.com. The API is on Railway at mytreda-production.app. Those are two completely different registrable domains not subdomains of the same site, genuinely different sites. Which means the refresh cookie, from the browser's point of view, is a third-party cookie.

Desktop Chrome (at the time of writing) still mostly allows third-party cookies. Mobile Safari and by extension every browser on iOS, since they're all WebKit under the hood, blocks third-party cookies by default via Intelligent Tracking Prevention (ITP). Doesn't matter that SameSite=None; Secure was set correctly on the cookie, which is the standard advice for "cookie needs to survive a cross-site fetch." ITP doesn't care about the SameSite attribute here; it's blocking the cookie because the request is cross-site, full stop.

So: refresh the tab on a laptop, cookie's there, silent refresh succeeds. Refresh on an iPhone, cookie was never allowed to persist, silent refresh 401s, and the app boots you to the login screen. Every time. Not flaky but deterministic, just device-dependent in a way that was easy to misread as flakiness from the support message alone.

Symptom 2: the login form lies about being empty

Here's where it gets fun. Once you're bounced to /auth/login, the phone's own password manager kicks in and autofills the email and password fields, that's the browser being helpful. The fields visibly have text in them. But hit submit, and the form says "Email is required" / "Password is required."

The form is built with react-hook-form and an uncontrolled register() on plain <input> elements — the normal, correct way to build a form like this. RHF tracks a field's value by listening for the native input event, not by re-reading the DOM on submit. That's a deliberate performance choice (no re-render per keystroke) and it's fine 99% of the time.

The 1%: OS/browser-level autofill (this is especially true of mobile Safari's Keychain-backed autofill, and some in-app WebView autofill implementations) can write directly into the DOM's value property without dispatching a trusted input event. The box looks filled. React's world model says it's still ''. Submit runs validation against React's world model, not the pixels on screen, so it says "required." Then the user, confused, deletes and retypes one character, a real keystroke fires a real input event and suddenly it works. That's exactly the repro the user described, verbatim, before I'd even opened the file.

Two bugs. Zero relation to each other. Both mobile-specific. Both look, from a support ticket, like one flaky mobile thing.

The fixes

For the autofill desync, the fix is a small, slightly gross, extremely standard trick: give the browser's own :-webkit-autofill pseudo-class a CSS animation, and listen for animationstart in JS. When it fires, read e.currentTarget.value (the real DOM value) and push it into RHF with setValue(field, value, { shouldValidate: true }).

onAutofillStart
@keyframes onAutofillStart { 
  from {} to {} 
  }
  input:-webkit-autofill { animation-name: onAutofillStart; 
}
syncAutofill
const syncAutofill = (field: 'email' | 'password') => (e: AnimationEvent<HTMLInputElement>) => { 
  if (e.animationName !== 'onAutofillStart') return; 
  setValue(field, e.currentTarget.value, { 
    shouldValidate: true, shouldDirty: true 
  }); 
};

No library, no rewrite to controlled inputs, no watching every keystroke. Just a hook into a browser behavior that has no other JS-visible signal.

For the cross-site cookie, the fix wasn't a code change at all — it was an infrastructure change. SameSite=None was already correct for a genuinely cross-site setup; the real problem was that the setup was cross-site in the first place. Putting the API behind a subdomain of the same site as the frontend — api.mytreda.com via a Cloudflare CNAME to Railway, instead of mytreda-production.app turns every request between them into a same-site request. ITP's third-party cookie block simply doesn't apply to same-site requests, regardless of what SameSite value the cookie carries. Once that migration was live, the cookie code didn't need to change at all to start working reliably on mobile, though tightening SameSite=None down to SameSite=Lax afterward is the correct hardening step now that cross-site delivery is no longer needed.

One Cloudflare CNAME record, one Railway custom domain, one Vercel env var pointing at the new host. That's the whole fix.

The bonus bug

While reading the cookie-setting code side by side, I found a sibling: the invitation-acceptance endpoint set its refresh cookie with a hardcoded sameSite: 'lax', not the environment-aware 'none' | 'lax' the main login endpoint used. In the cross-site production setup that existed until this fix, that meant the cookie set after accepting a team invite was Lax on a cross-site request which every browser, not just Safari, silently drops for fetch/XHR. Invited team members were plausibly getting logged out on first refresh on any device, and nobody had connected it to the same root cause because it never generated a support ticket that looked related.

The takeaway

"It logs me out on mobile" and "the form says my filled-in fields are empty" read, from a support channel, like one confusing report. They were two independent bugs that happened to both gate on whether the browser was mobile, which made them feel like a single bug long enough that neither fix was obvious from the ticket text alone. Reproducing it instead of just diagnosing it from the description is worth the extra five minutes, even when the description sounds complete. The user's exact words "delete and add one letter and it works" were the whole autofill bug in one sentence. I just had to go read the code to believe it.