Repository navigation
Allow configuring session lifetime (or sliding expiry) for pairing-issued sessions instead of a fixed 30-day TTL #13055
Description
Activity
Triage
Confirmed 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
- Default lifetime is hardcoded in
SessionStore:DEFAULT_SESSION_TTL = Duration.days(30)issueusesinput?.ttl ?? DEFAULT_SESSION_TTL
- Neither pairing redemption path passes a session TTL:
- Browser:
EnvironmentAuth.createBrowserSession→sessions.issue({ method: "browser-session-cookie", ... }) - Desktop/mobile (non-DPoP):
exchangeBootstrapCredentialForAccessTokenonly setsttl: Duration.hours(1)on the DPoP branch; plainbearer-access-tokeninherits the 30-day default
- Browser:
pairing create --ttlonly controls pairing-grant expiry inPairingGrantStore.issueOneTimeToken, not the session created on redeemmarkConnected/AuthSessions.setLastConnectedAtupdatelast_connected_atonly; there is noexpires_atupdate path- Session tokens embed absolute
expin signed claims, andSessionStore.verifyenforces that claim — so “slide the DB row” alone would not keep a stored bearer/cookie alive - There is no
ServerConfig/ env knob for session TTL. The only custom-TTL path ist3 auth session issue --ttl, which is outside the normal pairing UIs
This matches the reporter’s
auth_sessionsevidence (expires_at = issued_at + 30dwhilelast_connected_atkeeps moving).Related
- Session TTL is inverted vs credential strength: DPoP gets 1 hour, plain bearer gets 30 days #9884 asks about the inverted DPoP (1h) vs bearer (30d) choice and, in comments, for durable-until-revoke direct clients. Same root TTL surface; not a duplicate — that issue is about credential-strength / renewal model, this one is about making pairing-session lifetime operator-configurable (and optionally sliding).
Suggested direction
Product 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
expclaim.Labels:
enhancement,accepted,via-triage(removeneeds-triage)- Default lifetime is hardcoded in
- addedacceptedfeature request acceptedfeature request acceptedenhancementRequested improvement or new capability.Requested improvement or new capability.via-triageFiled through npx t3 triageFiled through npx t3 triage
on Sep 22, 2026 - added a commit that references this issue
on Sep 22, 2026 Tylerjet commented
on Sep 22, 2026 More actionsPreviously paired desktop environments reject credentials while all apps remain running
What happened
Two remote environments that previously worked now show this error in the macOS T3 Code desktop app:
Connection failed: The environment credential is invalid.
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.
Diagnosis
Confirmed: both remote servers answer discovery requests successfully, then reject the desktop client's bearer-authenticated WebSocket-ticket request with HTTP 401 and
invalid_credential. The client maps that response to the displayed message.The retained traces already show failures while the desktop was on
.2083, before its update to.2110. The authentication implementation did not change between those releases. No newer published fix was found in the release listing and main comparison checked during triage on September 22, 2026.Possible explanation, not confirmed for these sessions: the fixed lifetime of directly paired bearer credentials described in #13055. Source at release commit
aff9318bf46beaf05cc7155b428d3f0b8711efd2shows:- SessionStore defaults to 30 days and embeds an absolute expiry in the signed token. Activity updates
last_connected_at, not expiry. - Pairing exchange inherits that default for bearer credentials. The direct client stores and reuses the issued token without automatic renewal.
- Connection-runtime documentation explains that expiry does not close an established socket, but new HTTP requests and socket upgrades still need valid credentials. An app can therefore stay open while a later reconnection encounters expiry.
- Error mapping reports authentication rejection as the generic invalid-credential message.
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 gap
The 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:
- The README links to the repository's user guides but does not disclose the 30-day pairing-session lifetime. The GitHub wiki URL redirects to the repository home.
- The direct-pairing guide explains future reconnection but does not state the 30-day absolute lifetime, lack of renewal with activity, or eventual need to pair again.
- The same guide briefly mentions expired credentials in connection management and explains renewal for T3 Connect. It does not explain that directly paired connections have a different renewal limitation.
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 reproduce
Reported sequence, not a deterministic reproduction from fresh state:
- Pair a macOS desktop client with two remote environments over private-network HTTP endpoints.
- Use the connections successfully and keep the local and remote apps running.
- Subsequently observe both environments fail to connect with the error above.
Original pairing dates and the precise transition from working to failing have not been established. No fresh authenticated reproduction was run during triage.
Version
- Desktop:
0.0.43-nightly.20260922.2110at triage; failures also recorded on0.0.43-nightly.20260922.2083. - Both remote servers:
0.0.43-nightly.20260922.2083.
Environment
macOS client, Darwin 25.5.0, arm64. Triage CLI used Node v26.8.2 and was launched with
npx t3@nightly triage. Two remote PCs; their exact OS versions and server launch mechanisms were not independently verified. Direct bearer connections to private-network HTTP endpoints on port 3773.Evidence
Sanitized summary of retained client trace spans; hostnames, IP addresses, identifiers, credentials, and home paths omitted:
2026-09-22, client .2083 Remote A: discovery GET -> 200; websocket-ticket POST -> 401 First retained ticket rejection: 12:36:13.103500 UTC Remote B: discovery GET -> 200; websocket-ticket POST -> 401 First retained ticket rejection: 12:40:50.382700 UTC 2026-09-22, client .2110 Both remotes: discovery GET -> 200; websocket-ticket POST -> 401 EnvironmentAuthInvalidError: The environment rejected this client's credentials (invalid_credential). ConnectionBlockedError: The environment credential is invalid.Related issues
- #13055: fixed 30-day pairing-session lifetime and missing activity-based renewal. Possible match; this report does not independently confirm expired credentials.
- #13072: recurring connection loss recovered through re-pairing. Similar symptom; no service-update failure is established here.
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 workaround
None 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'
issued_at,expires_at,last_connected_at, andrevoked_at, plus the redacted server-side verification error corresponding to a failed ticket request.Filed by
Prepared by Codex (GPT-6) via
t3 triage.- SessionStore defaults to 30 days and embeds an absolute expiry in the signed token. Activity updates
+1 — same kind of deployment here:
t3 serveas a systemd user unit, reachable only over the tailnet, with the desktop app on two Macs and the iOS app.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
t3 auth session issue --ttl, but the iOS app can only redeem a pairing code, so for phones option 2 (sliding expiry on connect) or option 3 (TTL carried by the pairing grant) is the only real fix.Related: after re-pairing, our desktop app still did not reconnect because the environment had been switched off in the meantime — reported separately.
Reacted by jaypopat and Matt NodurfthWe hit this too: a single-operator
t3 servereachable only over Tailscale, used from the iOS app, the desktop app on two Macs, and a browser. Every device lost access on day 30 with no warning.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
- New authenticated endpoint that renews the current bearer session. It keeps the same session id, re-signs the token with a new
exp, and setsauth_sessions.expires_atin one guardedUPDATE … WHERE revoked_at IS NULL AND expires_at > now. That covers your point that the signedexpclaim means moving the DB row alone can't keep a token alive. - Renewal is only allowed in the last 7 days before expiry. Earlier calls get
session_renewal_not_due. - Only
bearer-access-tokensessions can renew. Browser cookie sessions and the 1-hour DPoP/relay path are unchanged (session_renewal_not_supported). - Revoked or already-expired sessions can't renew, so revocation stays authoritative, and a device unused for 30 days still expires.
Client (
packages/client-runtime)- When a saved bearer environment is within the 7-day window, the authorization layer calls the endpoint and stores the new token. Desktop, web and the Expo app get this without per-app changes.
Tradeoffs we know about
- The previous token stays valid until its original
exp, so for up to 7 days two tokens work for one session. We could close that by binding verification to a per-session issued-at or a token generation counter, if you'd prefer. - Sliding expiry means a leaked token can be kept alive by whoever uses it. Revoking the session is the control, same as today.
Relationship to #13092: they're compatible. A configurable
T3CODE_SESSION_TTLwould set the lifetime each renewal grants. #13092 alone moves the cliff further out without removing it, and phones can only redeem a pairing code, so they can't use thet3 auth session issue --ttlworkaround.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
mainand review it before opening anything.- New authenticated endpoint that renews the current bearer session. It keeps the same session id, re-signs the token with a new
+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.
Reacted by jaypopat, Andreas Lundgren, Sean Pianka and Anton Van EechauteAnother 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.
t3 auth session listshowsexpiresAt=issuedAt+ 30 days for each session, whatever itslastConnectedAt. Recovery needs a shell on the host: with the server bound to loopback behind Serve, the web UI doesn't offer Create link, so a new link has to come fromt3 pair --tailscale. If the sessions lapse while I'm away from home, the phone stays locked out until I can reach the host.For a setup like this, an operator-set lifetime (#13092's
T3CODE_SESSION_TTL) would be enough. Sessions stay revocable witht3 auth session revoke, and keeping the 30-day default leaves exposed setups as they are. Sliding expiry or durable device authorization would be welcome later, but the setting alone fixes the day-to-day problem.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
Session token expired., but that never reached the UI, so I had no idea it had expired and assumed it was an app bug. As others have said, the iOS app can only redeem a pairing code, sot3 auth session issue --ttldoesn't help on the phone.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.
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