Repository navigation
[Bug]: After re-pairing, an environment's other routes keep the expired login, and removing a route deletes the new one #17014
Description
Area
apps/mobile (shared logic in packages/client-runtime, so web and desktop are probably affected too)
Impact
Blocks work completely
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
What happened
My iOS app lost access to my Mac (T3 Code Nightly, reached over LAN and Tailscale) and I could not get it back without removing the whole environment.
- 6 Sep: paired the phone over LAN. The server issued a bearer session with the fixed 30-day lifetime (Allow configuring session lifetime (or sliding expiry) for pairing-issued sessions instead of a fixed 30-day TTL #13055).
- Oct 4–6: the app auto-updated to the 2.0.0 beta and kept connecting fine. Its last successful connection on the old login was Oct 6 04:57 UTC.
- Oct 6 11:37 UTC: the 30-day session expired. The app did not warn me and did not say it had expired.
- Oct 7: I re-paired over LAN with a new one-time code. The server issued a new session, valid until Nov 6, and the phone connected for about 5 minutes.
- Later, on another network: the app showed a connection timeout. Moving the Tailscale route to the top changed nothing: even after force-quitting and relaunching the app several times, it still showed the same timeout.
- Removed the LAN route: the app now showed "The environment credential is invalid." with no way to recover except removing the environment.
The server traces from step 6 show that the network was fine and that the phone sent the old, expired login over Tailscale:
GET http://<mac-tailnet-ip>:3773/.well-known/t3/environment 200 client=<phone-tailnet-ip> UA=T3Code/110 CFNetwork/3860.700.2 Darwin/25.6.0
POST http://<mac-tailnet-ip>:3773/api/auth/websocket-ticket 401 client=<phone-tailnet-ip>
EnvironmentAuth.authenticateHttpRequest → "Rejected authenticated session credential." reason="Session token expired."
The only expired, unrevoked session for this phone in auth_sessions is the one from 6 Sep. The Oct 7 session was still valid and was never used after its first 5 minutes.
Diagnosis (v0.0.46-nightly.20261007.2787)
Since #15467 (Oct 4), an environment holds several routes, and each route has its own credential. Re-pairing replaces the credential only on the route it pairs through. Every other route keeps the credential it already had, even an expired or revoked one.
- Route ids. Credentials are keyed by route id. Bearer routes are now
bearer:<envId>:<origin>(packages/client-runtime/src/connection/onboarding.ts:100-102). Routes saved before feat(clients): reach one environment over several routes #15467 kept the old idbearer:<envId>(the onboarding line that feat(clients): reach one environment over several routes #15467 changed;apps/mobile/src/connection/migration.ts:67), and nothing migrates them. - Re-pairing.
EnvironmentRegistry.registeradds the new route next to the existing ones. It only replaces a route with the same id or the same address (registry.ts:566-585,routes.ts:146-167). No step copies the new credential onto the environment's other bearer routes, and no step drops credentials that have expired. So a Sept route, or a route learned through it (feat(clients): learn an environment's LAN and tailnet addresses #15468,routes.ts:268-277,routes.ts:315-333), keeps the dead credential indefinitely. - Removing a route.
removeRoute→routesAfterRemovingdrops the route and deletes its credential (registry.ts:946-968,routes.ts:340-361). Removing the route that holds the new credential therefore leaves only the expired one. - Error precedence hides the cause.
connectOverRoutesskips routes that fail the 2.5 s check on the first pass and tries them last, and reportstransient ?? blocked(driver.ts:70-130). An unreachable LAN address's timeout therefore hides the 401 from the Tailscale route, and reordering routes has no visible effect. - No useful message. Any 401 maps to "The environment credential is invalid." (
errors.ts:121-126), discarding the server's reason ("Session token expired."). The supervisor then sits inblockedwith no re-pair prompt (supervisor.ts:927-942).
The server accepts a bearer session token on any of its addresses, so nothing on the server side requires one credential per direct route. Learned routes already share a credential.
Steps to reproduce
Revoking a session stands in for waiting 30 days.
- On iOS, remove the environment completely.
- Pair the phone using the host's LAN address. Confirm it connects.
- On the host, revoke that phone session in Settings → Connections. The phone reports "credential invalid", which is correct so far.
- Pair the phone again, this time using the host's Tailscale address.
- Remove the Tailscale route.
- Optional: before step 5, turn off Wi-Fi so the LAN route is unreachable, and note that the error shown is a timeout, not the auth failure.
Expected behavior
Re-pairing gives the environment a working login on every direct route. An expired or revoked login is never sent again.
Actual behavior
The other routes keep the dead login. Removing the newly paired route leaves the environment permanently on the dead login, with only "The environment credential is invalid." shown. While any route is unreachable, the auth failure is hidden behind a timeout.
Suggested direction
- One login per environment for direct routes. LAN, Tailscale and public-URL routes use the newest bearer credential. T3 Connect and SSH keep their own. Re-pairing over any address updates the credential for all of them.
- Drop dead logins. When the server rejects a credential as expired or revoked, delete it from the catalog so it cannot come back.
- Say what happened. Pass the server's reason through: "This login expired on ", with a one-tap Re-pair, rather than "credential invalid". Showing the expiry date before it lapses would avoid the surprise entirely; sliding expiry (Allow configuring session lifetime (or sliding expiry) for pairing-issued sessions instead of a fixed 30-day TTL #13055) would make it rare.
- Report the meaningful error. When every route fails, prefer the error from a route that reached the right server (a 401) over a timeout from a route that did not.
- Make it visible. Show the login status (issued, expires, last used) per environment, not per route, so it is clear that one login serves all routes.
Version or commit
iOS beta app 2.0.0 (build 110). Host: T3 Code Nightly 0.0.46-nightly.20261006.2752 desktop app on macOS. Code references checked against v0.0.46-nightly.20261007.2787.
Environment
macOS 26 host running the server bundled in the desktop app (port 3773), reached over LAN and Tailscale. iPhone on iOS 26 running the TestFlight beta of the app.
Workaround
Remove the whole environment on the phone (not individual routes) and pair again. This clears every route and credential.
Related
- Allow configuring session lifetime (or sliding expiry) for pairing-issued sessions instead of a fixed 30-day TTL #13055: fixed 30-day session lifetime. This is the trigger; this issue covers why the client cannot recover from it.
- [Bug]: Re-pairing a new server at the same address leaves a broken duplicate environment #15802: re-pairing at the same address leaves a broken duplicate. Same area, different trigger.
- feat(clients): reach one environment over several routes #15467 and feat(clients): learn an environment's LAN and tailnet addresses #15468: the changes that introduced per-route credentials and learned routes.
Activity
juliusmarminge commentedon Oct 8, 2026
Note
Grok responding on behalf of Julius.
Thanks for the detailed write-up. I checked it against
main(14fe0158ed) by reading the code; I haven't run the repro on a device.Confirmed in code
bearer:<envId>:<origin>(onboarding.ts#L94-L102, used at L136). The resolver looks up the credential by that id, and a learned route borrows the credential of the route it was learned through (resolver.ts#L150-L161, routes.ts#L311-L333).registerreplaces a route only when it has the same id or the same base URL, and otherwise inserts the new route next to the others (registry.ts#L566-L585, routes.ts#L143-L167). The catalog write stores the credential under the new id only (storageDocument.ts#L136-L160). Nothing updates the other bearer routes. Learned routes the server still reports keep their id, so they keep borrowing from whichever route they were learned through (routes.ts#L268-L274).bearer:<envId>(migration.ts#L67), and feat(clients): reach one environment over several routes #15467 changed onboarding away from that form. I found no code that renames old ids. One caveat: re-pairing at the same base URL does replace a legacy route, becausefindRouteToSameAddresscompares addresses, not ids.removeRoute→routesAfterRemovingdrops the route and any learned routes that borrow from it (registry.ts#L946-L968, routes.ts#L340-L361).setRoutesInCatalogthen deletes the dropped routes' credentials (storageDocument.ts#L77-L133). So in your reduced repro (pair over LAN, revoke, re-pair over Tailscale, remove the Tailscale route), only the LAN route is left, still holding the revoked token.connectOverRoutesreturnstransient ?? blocked, and it tries routes that failed the 2.5 s check last (driver.ts#L78-L135). A timeout on an unreachable LAN route is therefore reported instead of the 401 from Tailscale. The doc comment says this is deliberate, so the supervisor keeps retrying a route that may come back.EnvironmentAuthInvalidErroralways maps to "The environment credential is invalid." (errors.ts#L121-L126).ConnectionBlockedError, the supervisor goes toblockedand waits for a signal (supervisor.ts#L928-L944). The presentation layer turns that into anerrorphase that carries the message (presentation.ts#L51-L56). The shared mobile notice only offers Retry now (EnvironmentConnectionNotice.tsx#L88-L96). I didn't check every mobile screen.One correction
The client isn't dropping "Session token expired." That text is the server's log annotation (EnvironmentAuth.ts#L695-L705, SessionStore.ts#L105-L116). The 401 body only carries
reason: missing_credential | invalid_credential(environmentHttp.ts#L98-L101, http.ts#L96-L112). Showing "expired on " would need a contract change as well as a client change.Not verified
bearer:<envId>LAN route, re-pairing at the same LAN URL should have replaced that route and deleted its credential. The learned route would then point at a missing credential and fail with "Connection credential … is unavailable", not send the old token. Re-pairing doesn't cascade to learned routes the wayroutesAfterRemovingdoes. Can you say whether the Tailscale route was one you added yourself, or one the app learned? And was the September LAN pairing at exactly the same URL as the October one?Possible fixes (untested sketches)
routesAfterRemovingalready does.connectOverRoutes, keep retrying while any route failed transiently, but surface an authenticationblockederror from a route that reached the right server.EnvironmentAuthInvalidReason, and show a Pair again action on authentication failures.Workaround: as you found, remove the whole environment on the phone and pair again.
Related: #13055 (fixed 30-day session lifetime, which triggered this; open PR #13092 is titled as making the lifetime configurable), #15802 (re-pairing at the same address leaves a stale entry), #13072 and #8112 (other "credential is invalid / have to re-pair" reports). #5332 is closed. None of the open PRs I checked change this re-pair or removal path.
TinBane commentedon Oct 8, 2026
Thanks, the correction about the expiry reason is fair: it's only logged server-side, so fix 4 needs the contract change you describe.
On your questions:
From the user side, this is the core problem: scanning the QR code is how the routes came into being, so scanning it again reads as "refresh this machine". Nothing in the UI suggests the routes hold separate credentials, or that a rescan only updates one of them. I'd expect re-pairing to update every direct route of that environment, which is your fixes 1 and 2. Fixes 1–4 all look right to me.
Confirmed workaround: deleting the environment entirely and scanning the QR code again fixed it. It's now connecting over Tailscale. Removing individual routes and rescanning had not.