Skip to content

Allow configuring session lifetime (or sliding expiry) for pairing-issued sessions instead of a fixed 30-day TTL #13055

Description

@jaypopat

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:

  1. A server setting or env var for the default session TTL (e.g. to go beyond 30 days).
  2. Sliding expiry: extend expiresAt when an active session connects or is used.
  3. Carry a TTL from the pairing grant through to the session it creates (today pairing create --ttl only sets how long the pairing code can be redeemed).

Diagnosis

Checked against the source at tag v0.0.43-nightly.20260922.2096. SessionStore.ts and EnvironmentAuth.ts on main have had no changes since then.

  • apps/server/src/auth/SessionStore.ts:423: const DEFAULT_SESSION_TTL = Duration.days(30);, used at :658 as input?.ttl ?? DEFAULT_SESSION_TTL. No config or env override exists.
  • The two pairing redemption paths never pass a ttl:
    • Browser: EnvironmentAuth.createBrowserSession (EnvironmentAuth.ts:712-770) calls sessions.issue({ method: "browser-session-cookie", subject, scopes, client }). It has no ttl, and the grant carries none.
    • Desktop and mobile (direct pairing, no DPoP): exchangeBootstrapCredentialForAccessToken (EnvironmentAuth.ts:803-835) only sets ttl (1h) in the DPoP branch. The plain bearer-access-token branch 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 writes last_connected_at. No code path updates expires_at for a live session (persistence/AuthSessions.ts has no expiry-updating statement).
  • The only way to get a custom lifetime today is 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

  1. Run the server (t3 serve or the background service) and pair a browser, the desktop app, and the mobile app using the normal QR code or link.
  2. Use each device daily.
  3. Exactly 30 days after pairing, each client is signed out and has to be paired again. last_connected_at updates each day, but expires_at stays at issued_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

# auth_sessions rows (read-only query, identifiers/IPs omitted)
method                  device   surface  issued_at                 expires_at                last_connected_at
bearer-access-token     mobile   mobile   2026-08-18T18:04:20.620Z  2026-09-17T18:04:20.620Z  2026-09-13T10:35:29.265Z
bearer-access-token     desktop  desktop  2026-08-18T17:58:40.540Z  2026-09-17T17:58:40.540Z  2026-09-16T21:12:22.277Z
bearer-access-token     desktop  desktop  2026-08-18T17:53:48.559Z  2026-09-17T17:53:48.559Z  2026-09-16T21:14:55.888Z
# re-paired after expiry:
browser-session-cookie  desktop  web(FF)  2026-09-22T09:43:46.778Z  2026-10-22T09:43:46.778Z  2026-09-22T11:33:04.843Z
browser-session-cookie  desktop  web(Ch)  2026-09-22T09:54:53.810Z  2026-10-22T09:54:53.810Z  2026-09-22T10:10:45.786Z
bearer-access-token     desktop  desktop  2026-09-22T09:42:49.693Z  2026-10-22T09:42:49.693Z  2026-09-22T11:30:18.159Z
bearer-access-token     mobile   mobile   2026-09-22T11:34:37.230Z  2026-10-22T11:34:37.230Z  2026-09-22T11:42:51.310Z

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 --ttl doesn'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

Activity

  1. juliusmarminge commented on Sep 22, 2026

    @juliusmarminge
    Member

    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)
      • issue uses input?.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): exchangeBootstrapCredentialForAccessToken only sets ttl: Duration.hours(1) on the DPoP branch; plain bearer-access-token inherits the 30-day default
    • pairing create --ttl only controls pairing-grant expiry in PairingGrantStore.issueOneTimeToken, not the session created on redeem
    • markConnected / AuthSessions.setLastConnectedAt update last_connected_at only; there is no expires_at update path
    • Session tokens embed absolute exp in signed claims, and SessionStore.verify enforces 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 is t3 auth session issue --ttl, which is outside the normal pairing UIs

    This matches the reporter’s auth_sessions evidence (expires_at = issued_at + 30d while last_connected_at keeps moving).

    Related

    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 exp claim.

    Labels: enhancement, accepted, via-triage (remove needs-triage)

  2. added
    acceptedfeature request accepted
    enhancementRequested improvement or new capability.
    via-triageFiled through npx t3 triage
    on Sep 22, 2026
  3. Tylerjet commented on Sep 22, 2026

    @Tylerjet

    Previously 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 aff9318bf46beaf05cc7155b428d3f0b8711efd2 shows:

    • 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:

    1. Pair a macOS desktop client with two remote environments over private-network HTTP endpoints.
    2. Use the connections successfully and keep the local and remote apps running.
    3. 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.2110 at triage; failures also recorded on 0.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, and revoked_at, plus the redacted server-side verification error corresponding to a failed ticket request.

    Filed by

    Prepared by Codex (GPT-6) via t3 triage.

  4. allanben13 commented on Sep 23, 2026

    @allanben13

    +1 — same kind of deployment here: t3 serve as 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.

  5. roni-estein commented on Sep 23, 2026

    @roni-estein

    We hit this too: a single-operator t3 serve reachable 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 sets auth_sessions.expires_at in one guarded UPDATE … WHERE revoked_at IS NULL AND expires_at > now. That covers your point that the signed exp claim 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-token sessions 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_TTL would 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 the t3 auth session issue --ttl workaround.

    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 main and review it before opening anything.

  6. callmemorgan commented on Sep 27, 2026

    @callmemorgan
    Contributor

    +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.

  7. cal88 commented on Oct 3, 2026

    @cal88

    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. t3 auth session list shows expiresAt = issuedAt + 30 days for each session, whatever its lastConnectedAt. 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 from t3 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 with t3 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.

  8. TinBane commented on Oct 8, 2026

    @TinBane

    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, so t3 auth session issue --ttl doesn'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.

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

    acceptedfeature request acceptedenhancementRequested improvement or new capability.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