What happened
A relay client's 1-hour DPoP session lapses while its WebSocket stays alive. /api/auth/session
then returns HTTP 200 with authenticated:false. Because 200 is not an error, the client's
optimistic path never fires, and the UI reports the lapse as a permissions problem rather than
an expired session.
Diagnosis
/api/auth/session is the only auth route that cannot answer 401, and the authenticated:false
body it returns instead is indistinguishable from "this client never had a session". Three links:
1. The contract gives the route no 401 shape.
packages/contracts/src/environmentHttp.ts:412-416 declares the endpoint with
error: [EnvironmentInternalError]. Sibling auth routes carry
.middleware(EnvironmentAuthenticatedAuth); session deliberately does not, so an
authentication failure has nowhere to go in the response type.
2. The server collapses every credential failure into one body.
apps/server/src/auth/EnvironmentAuth.ts:646-651:
Effect.catchIf(isServerAuthCredentialError, () =>
Effect.succeed({
authenticated: false,
auth: descriptor,
} satisfies AuthSessionState),
),
Expired, malformed, revoked and absent credentials all produce the same value. The false branch
also omits scopes, sessionMethod and expiresAt (all optionalKey on AuthSessionState,
packages/contracts/src/auth.ts:341-347), so the response carries no evidence that a session
ever existed or when it lapsed.
3. The client's optimistic fallback is unreachable for a 200.
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts:86-102. The fallback at :94 is
return input.hasError ? "granted" : "denied", and it sits inside if (input.session === null).
A 200 with a well-formed body leaves session non-null and hasError false, so control reaches
:96, if (!input.session.authenticated) return "denied" — which renders as a permissions state.
The comment at :91-93 states the intent: a failed session fetch is a transport problem, not a
permission decision, so stay optimistic. An expired session is neither, and nothing on the wire
lets the client tell it apart from a genuine denial.
Why the socket outlives the credential. The WebSocket authenticates once at upgrade via the
wsTicket query param (EnvironmentAuth.ts:945), not per-frame against the access token. The
access token TTL is Duration.hours(1) (EnvironmentAuth.ts:709). Nothing renegotiates when it
lapses, so no reconnect forces re-auth and the stale state persists.
Not fixed on main. getSessionState last changed 2026-07-27 (23b55022); main is identical
to v0.0.34 for all three files above, client logic included.
Steps to reproduce
Against any running environment server, no expiry wait needed — a malformed DPoP credential takes
the same isServerAuthCredentialError branch as an expired one:
curl -i http://127.0.0.1:<port>/api/auth/session → 200, authenticated:false.
curl -i -H 'authorization: DPoP not-a-real-token' -H 'dpop: not-a-real-proof' http://127.0.0.1:<port>/api/auth/session
→ 200, byte-identical body. No www-authenticate, no reason.
curl -i -X POST -H 'authorization: DPoP not-a-real-token' -H 'dpop: not-a-real-proof' http://127.0.0.1:<port>/api/auth/websocket-ticket
→ 401, showing the asymmetry between this route and its siblings.
For the full user-visible path: connect a desktop client to an environment over the relay, leave
it idle past the 1-hour access-token TTL without letting the WebSocket drop, then open provider
settings. The panel reports denied permissions rather than an expired session.
Version
0.0.34 (v0.0.34, latest stable at time of filing)
Environment
Server: Linux x64 6.8.0-138-generic, Node v22.23.0. Client: T3 Code desktop on Windows 10,
Electron 41.5.0 / Chrome 146, connecting through a managed relay (*.t3coderelay.com).
Evidence
# Live against the running server. Bodies below are verbatim except for the
# environment-identifying cookie-name suffix.
$ curl -s -o /dev/null -w '%{http_code}' /api/auth/session
200
{"authenticated":false,"auth":{"policy":"loopback-browser","bootstrapMethods":["one-time-token"],
"sessionMethods":["browser-session-cookie","bearer-access-token","dpop-access-token"],
"sessionCookieName":"t3_session_<redacted>"}}
$ curl -s -H 'authorization: DPoP not-a-real-token' -H 'dpop: not-a-real-proof' \
-o /dev/null -w '%{http_code}' /api/auth/session
200
# body byte-identical to the unauthenticated request above
$ curl -s -X POST -H 'authorization: DPoP not-a-real-token' -H 'dpop: not-a-real-proof' \
-o /dev/null -w '%{http_code}' /api/auth/websocket-ticket
401
# server.trace.ndjson — the relay client's session probe, resolving Success at 200
{"type":"effect-span","name":"environment.auth.session","traceId":"4f949cdd2b7b6d14507b49f8bc9b3052",
"attributes":{"environment.endpoint":"HttpApiEndpoint","http.request.method":"GET",
"url.path":"/api/auth/session"},"exit":{"_tag":"Success"}}
Related issues
#7756 (browser pairing returns authenticated but omits the t3_session cookie) and #8112
(environment credential invalid after a clean relink) both touch session state, but neither
covers an expired credential being reported as 200 authenticated:false. #7878 concerns a
missing re-login affordance for Claude provider auth, not environment sessions. No duplicate found.
Fix applied or workaround
None. The mismatch is in the wire contract, so there is no configuration or service-level
remedy to apply on the affected machine. Nothing was written to the user's database or settings
during triage; the investigation was read-only apart from the three curl probes above.
Filed by
claude (Opus 5) via t3 triage
What happened
A relay client's 1-hour DPoP session lapses while its WebSocket stays alive.
/api/auth/sessionthen returns HTTP 200 with
authenticated:false. Because 200 is not an error, the client'soptimistic path never fires, and the UI reports the lapse as a permissions problem rather than
an expired session.
Diagnosis
/api/auth/sessionis the only auth route that cannot answer 401, and theauthenticated:falsebody it returns instead is indistinguishable from "this client never had a session". Three links:
1. The contract gives the route no 401 shape.
packages/contracts/src/environmentHttp.ts:412-416declares the endpoint witherror: [EnvironmentInternalError]. Sibling auth routes carry.middleware(EnvironmentAuthenticatedAuth);sessiondeliberately does not, so anauthentication failure has nowhere to go in the response type.
2. The server collapses every credential failure into one body.
apps/server/src/auth/EnvironmentAuth.ts:646-651:Expired, malformed, revoked and absent credentials all produce the same value. The false branch
also omits
scopes,sessionMethodandexpiresAt(alloptionalKeyonAuthSessionState,packages/contracts/src/auth.ts:341-347), so the response carries no evidence that a sessionever existed or when it lapsed.
3. The client's optimistic fallback is unreachable for a 200.
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts:86-102. The fallback at :94 isreturn input.hasError ? "granted" : "denied", and it sits insideif (input.session === null).A 200 with a well-formed body leaves
sessionnon-null andhasErrorfalse, so control reaches:96,
if (!input.session.authenticated) return "denied"— which renders as a permissions state.The comment at :91-93 states the intent: a failed session fetch is a transport problem, not a
permission decision, so stay optimistic. An expired session is neither, and nothing on the wire
lets the client tell it apart from a genuine denial.
Why the socket outlives the credential. The WebSocket authenticates once at upgrade via the
wsTicketquery param (EnvironmentAuth.ts:945), not per-frame against the access token. Theaccess token TTL is
Duration.hours(1)(EnvironmentAuth.ts:709). Nothing renegotiates when itlapses, so no reconnect forces re-auth and the stale state persists.
Not fixed on
main.getSessionStatelast changed 2026-07-27 (23b55022);mainis identicalto
v0.0.34for all three files above, client logic included.Steps to reproduce
Against any running environment server, no expiry wait needed — a malformed DPoP credential takes
the same
isServerAuthCredentialErrorbranch as an expired one:curl -i http://127.0.0.1:<port>/api/auth/session→ 200,authenticated:false.curl -i -H 'authorization: DPoP not-a-real-token' -H 'dpop: not-a-real-proof' http://127.0.0.1:<port>/api/auth/session→ 200, byte-identical body. No
www-authenticate, noreason.curl -i -X POST -H 'authorization: DPoP not-a-real-token' -H 'dpop: not-a-real-proof' http://127.0.0.1:<port>/api/auth/websocket-ticket→ 401, showing the asymmetry between this route and its siblings.
For the full user-visible path: connect a desktop client to an environment over the relay, leave
it idle past the 1-hour access-token TTL without letting the WebSocket drop, then open provider
settings. The panel reports denied permissions rather than an expired session.
Version
0.0.34 (v0.0.34, latest stable at time of filing)
Environment
Server: Linux x64 6.8.0-138-generic, Node v22.23.0. Client: T3 Code desktop on Windows 10,
Electron 41.5.0 / Chrome 146, connecting through a managed relay (
*.t3coderelay.com).Evidence
Related issues
#7756 (browser pairing returns authenticated but omits the
t3_sessioncookie) and #8112(environment credential invalid after a clean relink) both touch session state, but neither
covers an expired credential being reported as 200
authenticated:false. #7878 concerns amissing re-login affordance for Claude provider auth, not environment sessions. No duplicate found.
Fix applied or workaround
None. The mismatch is in the wire contract, so there is no configuration or service-level
remedy to apply on the affected machine. Nothing was written to the user's database or settings
during triage; the investigation was read-only apart from the three
curlprobes above.Filed by
claude (Opus 5) via
t3 triage