Skip to content

[Bug]: After re-pairing, an environment's other routes keep the expired login, and removing a route deletes the new one #17014

Description

@TinBane

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.

  1. 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).
  2. 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.
  3. Oct 6 11:37 UTC: the 30-day session expired. The app did not warn me and did not say it had expired.
  4. 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.
  5. 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.
  6. 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 id bearer:<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.register adds 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 → routesAfterRemoving drops 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. connectOverRoutes skips routes that fail the 2.5 s check on the first pass and tries them last, and reports transient ?? 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 in blocked with 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.

  1. On iOS, remove the environment completely.
  2. Pair the phone using the host's LAN address. Confirm it connects.
  3. On the host, revoke that phone session in Settings → Connections. The phone reports "credential invalid", which is correct so far.
  4. Pair the phone again, this time using the host's Tailscale address.
  5. Remove the Tailscale route.
  6. 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

  1. 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.
  2. Drop dead logins. When the server rejects a credential as expired or revoked, delete it from the catalog so it cannot come back.
  3. 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.
  4. 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.
  5. 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

Activity

  1. juliusmarminge commented on Oct 8, 2026

    @juliusmarminge
    Member

    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

    • Credentials are per route. A paired route's id is 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).
    • Re-pairing only writes the new route's credential. register replaces 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).
    • Legacy ids aren't rewritten. The legacy mobile migration still writes 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, because findRouteToSameAddress compares addresses, not ids.
    • Removing a route deletes its credential. removeRoute → routesAfterRemoving drops the route and any learned routes that borrow from it (registry.ts#L946-L968, routes.ts#L340-L361). setRoutesInCatalog then 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.
    • Transient errors win over blocked ones. connectOverRoutes returns transient ?? 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.
    • Every 401 shows the same message. EnvironmentAuthInvalidError always maps to "The environment credential is invalid." (errors.ts#L121-L126).
    • Blocked has no re-pair action. On a ConnectionBlockedError, the supervisor goes to blocked and waits for a signal (supervisor.ts#L928-L944). The presentation layer turns that into an error phase 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

    • I couldn't reconstruct from code exactly how your Tailscale route ended up holding the September token. If it was a route learned through the legacy 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 way routesAfterRemoving does. 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)

    1. After a bearer pairing, write the new token to every direct bearer route of that environment. Another option is to key direct-route credentials per environment instead of per route, and keep relay and SSH separate.
    2. When re-pairing replaces a route, re-point or drop the learned routes that borrow from it, matching what routesAfterRemoving already does.
    3. In connectOverRoutes, keep retrying while any route failed transiently, but surface an authentication blocked error from a route that reached the right server.
    4. Add an expired/revoked reason to 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.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 8, 2026
  3. TinBane commented on Oct 8, 2026

    @TinBane
    Author

    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:

    • I never added a route by hand. I paired by scanning the QR code, and the other routes (Tailscale included) appeared on their own, so they were learned, not added. When the session lapsed, I scanned the QR code again to refresh the credentials. That only refreshed the LAN route; the learned routes kept the old token.
    • The host's records agree: the phone only ever redeemed two pairing codes (6 Sep and 7 Oct), both from LAN client IPs on different subnets, so the two pairings were at different base URLs and the second didn't replace the first route. No code was ever redeemed over Tailscale.

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions