Repository navigation
Replies: 13 comments
TriageConfirmed as a real product gap, not a misread of the current design. Pairing-issued sessions have a fixed absolute 30-day TTL with no operator override and no activity-based extension. What the code does today
This matches the reporter’s Related
Suggested directionProduct call first (absolute longer TTL vs sliding vs durable device auth + rotated short tokens). Smallest useful change after that: a server setting/env for default pairing-session TTL, applied explicitly at both pairing issue call sites, plus user/ops docs. Sliding expiry or auto-refresh is a larger follow-up because of the signed Labels: |
Previously paired desktop environments reject credentials while all apps remain runningWhat happenedTwo remote environments that previously worked now show this error in the macOS T3 Code desktop app:
The user reports that the local app and the app instances on both remote PCs remain running. They expected those connections to remain usable without manually pairing again. DiagnosisConfirmed: both remote servers answer discovery requests successfully, then reject the desktop client's bearer-authenticated WebSocket-ticket request with HTTP 401 and The retained traces already show failures while the desktop was on Possible explanation, not confirmed for these sessions: the fixed lifetime of directly paired bearer credentials described in #13055. Source at release commit
The affected sessions' issue/expiry/revocation timestamps and remote verification errors were not available. Expiry cannot yet be distinguished from revocation, missing session records, or signature failure. The event that triggered reconnection is also unknown. No credential was decrypted, and the secrets directory was not read. Documentation gapThe user was unaware that a paired device could require pairing again after a fixed lifetime. The installed-release documentation and the current main README and remote-access guide were checked on September 22, 2026:
Regardless of whether expiry caused these particular failures, the direct-pairing guide should disclose the lifetime and recovery steps. An expiry-specific client message would also make recovery easier than the current generic invalid-credential message. Steps to reproduceReported sequence, not a deterministic reproduction from fresh state:
Original pairing dates and the precise transition from working to failing have not been established. No fresh authenticated reproduction was run during triage. Version
EnvironmentmacOS client, Darwin 25.5.0, arm64. Triage CLI used Node v26.8.2 and was launched with EvidenceSanitized summary of retained client trace spans; hostnames, IP addresses, identifiers, credentials, and home paths omitted: Related issues
This is intended as additional evidence and user impact on an existing discussion, not a claim of a new confirmed root cause. Fix applied or workaroundNone applied. Fresh pairing links from each host were proposed, but have not been generated or redeemed. No authentication state, database records, or application source were changed. To resolve the remaining uncertainty, inspect only the affected remote sessions' Filed byPrepared by Codex (GPT-6) via |
|
+1 — same kind of deployment here: On day 30 our desktop clients lost access without warning, and from the client side it looked like a server outage rather than an expired pairing. We worked around it on desktop by provisioning a bearer session issued with Related: after re-pairing, our desktop app still did not reconnect because the environment had been switched off in the meantime — reported separately. |
|
We hit this too: a single-operator I'd like to propose option 2 (sliding expiry) and offer a PR if it's the direction you'd accept. We have a working prototype (+544/−5, about half of it tests) that we haven't submitted. Here's the design so you can say yes or no before anyone spends review time on it. Server
Client (
Tradeoffs we know about
Relationship to #13092: they're compatible. A configurable If you'd rather go with option 3 (TTL carried by the pairing grant) or a different renewal shape, we'll follow that and drop this. The prototype was written with Codex. We'd rebase it onto current |
|
+1 on this issue. I'm not sure what the best solution is, but having to have codex ssh around and copy in some new pairing urls ain't it. |
|
Another data point for the product decision. Setup: T3 Code 0.0.45 as the background service (systemd user unit) on a home server. It listens on 127.0.0.1 only and is published to my tailnet with Tailscale Serve. One operator, and every client is my own device: the Windows desktop app and browsers so far, Android phones next. Every device paired on the same day expires on the same day. For a setup like this, an operator-set lifetime (#13092's |
|
Another data point: a single-operator setup with the T3 Code Nightly desktop app on macOS, the iOS app over LAN and Tailscale, all my own devices. The phone's session expired on day 30 with no warning, and nothing in the app said so. It showed a connection timeout, even after reordering routes and relaunching the app several times, because an unreachable LAN route masked the 401. Only after I removed that route did it say "The environment credential is invalid." The server logged the reason as Re-pairing didn't cleanly recover it either. Since environments got multiple routes (#15467), re-pairing replaces the login only on the route it pairs through, so the expired login stayed on the Tailscale route and became the only one left once I removed the LAN route. I've filed that separately as #17014. +1 for sliding expiry (option 2). Even without it, the client could show "login expires on " and a one-tap re-pair when the server reports an expired session, which would turn day 30 into a 10-second fix instead of a debugging session. |
|
A data point from a fork that ships option 2 (sliding expiry). It has run since Oct 20 on our own server, with the macOS desktop app, Android and browsers as clients.
Commit with tests: jason5545@34194ae104 (current version: The trade-off is that a bearer token's |
|
I have the same setup as several people here: an iPhone paired directly with my Mac over Tailscale Serve. Its session runs out 30 days after pairing however often I use it, and since the app can only redeem a pairing code, the only fix is pairing again. I'd like to propose a renewal design that answers the objection that ended desktop-session renewal in #16274. Where things stand, at main @
Proposal: rotation with reuse detection This is refresh-token rotation as described in RFC 9700, section 4.14.2. Section 2.2.2 of the same RFC says a public client's refresh tokens must be either sender-constrained or rotated; this is the rotated option.
What this doesn't cover: if the phone itself is stolen, the thief holds the only live copy and can keep it alive until the session is revoked on the host, where today it would lapse within 30 days. An absolute cap counted from the original pairing, such as a year, would bound that if you want one. Alternative: bind direct pairings to a key This is the other option RFC 9700 allows. The client generates a proof key when it pairs, using the DPoP signer the clients already use for T3 Connect. The pairing becomes a durable device grant bound to that key, and the client exchanges a DPoP proof for one-hour tokens, as Connect does. A token copied off the device is useless without the key, which answers the stolen-token objection completely and removes the long-lived unbound bearer that #9884 is about, as seanpianka suggested there. It's the bigger change: a new kind of durable grant on the server, and non-exportable key storage in each client. Which of these would you accept? I'm happy to build either one, with server and client tests, once you've agreed the direction and scope. |
|
Follow-up to my comment above: sg448's objection holds for the design I posted, so the fork changed it on Oct 27. A session now renews only when the connection comes from the device it was issued to, which on a tailnet the network layer can already tell us.
This is a data point for setups on Tailscale only: the device identity comes from Tailscale's node key, which upstream's direct pairing doesn't have. For upstream, sg448's key-bound alternative looks like the general form of the same idea, with the DPoP key playing the part the node key plays here. The gap is the same as theirs: a stolen device keeps renewing until it is revoked on the host. Commit: jason5545@ffb2744f85 ( |
|
An update: I built the key-bound version on a fork (with main up to
Testing it live with a 5-minute lifetime turned up one thing worth knowing for anyone building this. The client counts the lifetime from when the token arrives, in whole seconds ( One finding along the way: the web front of The fork is private, but I'm happy to turn this into a pull request here if you'd accept this direction. Android would need the same from its Keystore, and web and desktop clients would stay on bearer pairing unless they get a hardware-backed key too. |
|
We hit this too. I run T3 Code on three hosts (a MacBook, a Mac mini and a Linux box), all reachable only over my tailnet, and use them from the iOS app and from each other's desktop apps. Every pairing expires 30 days after it was issued, regardless of daily use, and each one on a different date. Today my iPhone suddenly showed "The environment credential is invalid" for the MacBook. The server log said My current workaround is to have a coding agent SSH into each host, run For single-operator setups where the server is only reachable over Tailscale (loopback behind Tailscale Serve, or tailnet-only), I'd love an operator-set session lifetime that can also be "never expires", e.g. Posted by Claude Opus 5.5 on behalf of Archit Pai. |
|
Another Android data point: I use the T3 Code Android app (2.2.0) with three Linux hosts over Tailscale HTTPS. Today the app could not connect to any of them. Two showed “did not accept this device”; the expanded diagnostic said “The environment credential is invalid.” The third showed a timeout on an automatically learned direct route. The phone’s Tailscale VPN was connected, all three hosts were reachable, and fresh pairing codes restored all three connections. The newly issued mobile sessions each expire exactly 30 days after issue. We did not recover the old sessions’ expiry/revocation records, so I cannot prove expiry caused this particular incident. For my setup, an opt-in six-month lifetime for sessions created through normal phone pairing would already solve most of the friction. Keeping the 30-day default and allowing individual revocation would be fine. Device-bound automatic renewal would be even better, including Android support, without requiring T3 Connect. Please also show the session expiry date and distinguish expired credentials from revoked/invalid ones in the app. A generic rejection or a timeout from another route makes this unnecessarily hard to diagnose. Posted by Codex on my behalf. |
Uh oh!
There was an error while loading. Please reload this page.
What happened
Feature request, not a bug. I run T3 Code as a background service (systemd user unit) on a home server, reachable only over my Tailnet / reverse proxy. Every client is my own hardware: the desktop Electron app, Chrome, Firefox, and the mobile app.
Every session created through the normal pairing flow (QR code or link) expires exactly 30 days after it was issued, even on devices I use every day. Activity never extends a session, so each device has to be re-paired by hand every month. On a private, single-operator deployment that is unnecessary friction, and I can't configure it.
The request is to make session lifetime for pairing-issued sessions configurable, in any of these ways:
expiresAtwhen an active session connects or is used.pairing create --ttlonly sets how long the pairing code can be redeemed).Diagnosis
Checked against the source at tag
v0.0.43-nightly.20260922.2096.SessionStore.tsandEnvironmentAuth.tsonmainhave had no changes since then.apps/server/src/auth/SessionStore.ts:423:const DEFAULT_SESSION_TTL = Duration.days(30);, used at:658asinput?.ttl ?? DEFAULT_SESSION_TTL. No config or env override exists.ttl:EnvironmentAuth.createBrowserSession(EnvironmentAuth.ts:712-770) callssessions.issue({ method: "browser-session-cookie", subject, scopes, client }). It has nottl, and the grant carries none.exchangeBootstrapCredentialForAccessToken(EnvironmentAuth.ts:803-835) only setsttl(1h) in the DPoP branch. The plainbearer-access-tokenbranch falls back to the 30-day default.pairing create --ttl→createPairingLink(EnvironmentAuth.ts:884-895) →PairingGrantStore.issueOneTimeToken(PairingGrantStore.ts:385-389). That TTL only sets when the pairing credential expires. It is not stored on the grant for use when the session is issued.SessionStore.markConnected(SessionStore.ts:566-600) only writeslast_connected_at. No code path updatesexpires_atfor a live session (persistence/AuthSessions.tshas no expiry-updating statement).t3 auth session issue --ttl(EnvironmentAuth.issueSession,:932-942). It mints a raw admin bearer token outside the pairing flow, so the browser and mobile pairing UIs can't use it.Steps to reproduce
t3 serveor the background service) and pair a browser, the desktop app, and the mobile app using the normal QR code or link.last_connected_atupdates each day, butexpires_atstays atissued_at + 30d.Version
0.0.43-nightly.20260922.2096
Environment
Linux x64 (Debian, kernel 6.1), Node v26.8.2, server run as a systemd user service behind Tailnet/reverse proxy; clients: desktop (Electron), Chrome, Firefox, mobile app
Evidence
Sessions last used 1–4 days before expiry still expired at exactly
issued_at + 30d.Related issues
#9884 (Session TTL is inverted vs credential strength: DPoP 1h, bearer 30d). Related, but it pushes the other way and asks for an explicit, shorter bearer TTL. Not a duplicate: this issue asks for the pairing-issued session lifetime to be configurable by the operator and/or to slide with activity. A configurable TTL, set explicitly at the pairing call sites, would address both issues: a short default for exposed deployments, and a longer or sliding lifetime for private single-operator ones.
Fix applied or workaround
None applied. The only workaround is re-pairing every 30 days.
t3 auth session issue --ttldoesn't help, because the browser and mobile pairing flows can't redeem the token it mints.Filed by
Claude Code (claude-opus-5) via t3 triage
All reactions