Skip to content

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

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.

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