Skip to content

Session TTL is inverted vs credential strength: DPoP gets 1 hour, plain bearer gets 30 days #9884

Open

Description

@johnhenry

Summary

Session lifetime is selected by whether the client presented a DPoP proof-of-possession key. The client that presents one gets 1 hour. The client that presents none — a plain bearer token — gets the default 30 days. The stronger credential is the short-lived one.

Steps to reproduce

Read apps/server/src/auth/EnvironmentAuth.ts:742-751, in exchangeBootstrapCredentialForAccessToken:

return yield* sessions
  .issue({
    method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",
    subject: grant.subject,
    scopes: grantedScopes,
    ...(input?.proofKeyThumbprint
      ? {
          proofKeyThumbprint: input.proofKeyThumbprint,
          ttl: Duration.hours(1),
        }
      : {}),

ttl is inside the spread that only fires when proofKeyThumbprint is set. With no proof key the key is absent entirely, so SessionStore.issue falls through to its default:

apps/server/src/auth/SessionStore.ts:419

const DEFAULT_SESSION_TTL = Duration.days(30);

apps/server/src/auth/SessionStore.ts:625

milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),

proofKeyThumbprint is populated only from a DPoP request header (apps/server/src/auth/http.ts:320-335), and the DPoP path is the cloud/relay-managed one — apps/mobile/src/connection/migration.ts:44 treats relayManaged === true || authenticationMethod === "dpop" as the same class, and the proof is built in apps/mobile/src/features/cloud/linkEnvironment.ts:531-540. A directly-paired client sends no DPoP header and therefore takes the 30-day branch.

Expected behavior

Session lifetime scales inversely with credential strength: a sender-constrained token, which cannot be replayed from another key, can afford a longer life than a bearer token, which anyone holding it can present.

Actual behavior

Exactly inverted:

client credential replayable by a holder TTL
relay / cloud-linked DPoP, sender-constrained no 1 hour
directly paired plain bearer yes 30 days

Impact

The 30-day bearer session is the one on a phone — the device most likely to be lost or stolen, and the one whose token is not bound to any key. It also carries whatever scopes the pairing link granted, which by default includes AuthTerminalOperateScope ("Create terminals and send input to running shells").

Revocation exists and works (Settings → Connections lists client sessions with a revoke action), so this is bounded by the user noticing and acting — but noticing is the whole gap that a short TTL is meant to cover.

The nearby TTL constants all carry a comment explaining the number (DESKTOP_BOOTSTRAP_TTL_HOURS, DEV_STARTUP_TTL_HOURS in PairingGrantStore.ts:245-258 are both well argued). Duration.hours(1) here has none, which makes me think the 1-hour value was chosen deliberately for DPoP and the 30-day default was simply what the other branch inherited, rather than a decision anyone made about paired phones.

Suggested fix

Set the bearer TTL explicitly rather than letting it fall through to the store default — whatever the right number is, it should be stated at this call site and not inherited. If 30 days is intentional for paired devices, a comment saying so would stop this reading as an oversight.

Version

d7cf8aaa (main).

Activity

  1. seanpianka commented on Sep 10, 2026

    @seanpianka

    I ran into a separate consequence of the same TTL setup: direct clients cannot renew at all.

    A client paired through npx t3 pair --tailscale stores the environment bearer session and reuses it. When its absolute 30-day expiry arrives, that client cannot exchange or refresh the session. It has to be paired again. In my case, three environments and five regular clients create about 13 remote saved connections that would periodically need manual pairing. That makes direct Tailscale access unable to replace T3 Connect for me, even though network reachability remains healthy.

    Is recurring re-pairing the intended behavior for direct clients? If it is, what threat model requires the client authorization itself to expire when the environment can already revoke that session? If it isn't, would you be open to a durable, revocable device authorization which obtains rotated, bounded access tokens directly from the environment, without depending on Clerk or the T3 relay?

    The invariant I'm after is simple: once I pair a direct client, it stays authorized until I explicitly revoke it. The bearer token does not have to last forever. It can rotate automatically. I would be happy to work on an upstream implementation after the intended trust model and compatibility boundary are clear.

  2. rodalpho commented on Nov 4, 2026

    @rodalpho

    Why do phone creds expire at all? This is confusing as it just stops working one day and needs to be recreated. Was this a human design decision or one an agent decided that was not examined?

  3. eyeveil commented on Nov 7, 2026

    @eyeveil
    Contributor

    Note

    Written by claude-opus-5-5 on behalf of eyeveil

    We hit this on a fork. Links paired with t3 pair died on schedule after 30 days, with no way to renew. We built a fix there and would like to offer it upstream if you want it: issuelt#53

    What it does. A client that pairs directly with a server pairs with the same device DPoP key T3 Connect already uses. It then renews its 1-hour session at /oauth/token, presenting its current access token as the subject together with a proof from that key. Renewal works up to 30 days after the token expired. A link in use never expires, and a link idle for longer than 30 days pairs again. A still-valid bearer from a pairing code converts to DPoP once, and the server revokes that bearer. Clients do any of this only when the server advertises dpopRenewal. Against older servers they pair with a bearer, as they do today.

    On the concern behind #16274, that a stolen session could renew itself forever:

    • Renewal needs a proof from the device key. Web and desktop hold that key as non-exportable, so a copied token alone can't renew.
    • Only pairing-code sessions can renew or convert. T3 Connect, dev-token, CLI, and desktop sessions are refused, so unlinking Connect or rotating a dev token still cuts a device off.
    • Each session records its key and predecessor. Revoking any session revokes the device's whole chain and closes its sockets. Sharing & access lists an idle device for its full renewal window, so you can revoke it.
    • Scopes only intersect. A read-only link stays read-only through every renewal.

    The server half is self-contained:

    • the grant
    • a migration adding two columns
    • the family revoke
    • the descriptor flag

    It would be a fair first PR on its own, with the client half after it. Would you take it? If so, I'll open it from eyeveil/t3code with "Allow edits by maintainers" on.

  4. rodalpho commented on Nov 8, 2026

    @rodalpho

    I would like my connections to never, ever, expire.

    The way this works now is you pull out your phone and sometimes it works and sometimes it doesn't. And then you can't do any work until you return to your computer and regenerate the code.

    This is a very poor user experience.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions