Repository navigation
Description
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
seanpianka commentedon Sep 10, 2026
I ran into a separate consequence of the same TTL setup: direct clients cannot renew at all.
A client paired through
npx t3 pair --tailscalestores 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.
rodalpho commentedon Nov 4, 2026
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?
eyeveil commentedon Nov 7, 2026
Note
Written by
claude-opus-5-5on behalf of eyeveilWe hit this on a fork. Links paired with
t3 pairdied 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#53What 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 advertisesdpopRenewal. 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:
The server half is self-contained:
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/t3codewith "Allow edits by maintainers" on.rodalpho commentedon Nov 8, 2026
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.