Skip to content

Expired DPoP session returns HTTP 200 with authenticated:false, so clients report it as a permissions denial #8326

Description

@psicilia5179

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:

  1. curl -i http://127.0.0.1:<port>/api/auth/session → 200, authenticated:false.
  2. 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.
  3. 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

Activity

  1. eugkhp commented on Aug 27, 2026

    @eugkhp

    Same root cause, different (and harder-failing) surface: the pull request Code tab.

    What happened

    Mac desktop (T3 Code 0.0.34) connected through T3 Connect to a Linux environment (desktop-managed server 0.0.34). About an hour after connecting, opening a GitLab MR shows Summary and Timeline normally, but the Code tab shows 0 files and:

    The environment rejected this client's credentials (invalid_credential).

    The MR has 6 files; glab api …/merge_requests/N/diffs works on the environment host.

    Diagnosis

    Same lapse as described above — the relay client's 1-hour DPoP access token expires while the WebSocket (authenticated once at upgrade via wsTicket) stays alive — but here it hits a route that does carry the 401 shape:

    • The Code tab is the one PR view that goes over HTTP instead of the RPC socket: packages/contracts/src/environmentHttp.ts — EnvironmentPullRequestsHttpApi … post("diff", "/api/pull-requests/diff", …).middleware(EnvironmentAuthenticatedAuth) ("Large, compressible pull-request payloads travel over HTTP rather than the RPC socket"). Summary/Timeline use pullRequests.detail / pullRequests.activity over the socket, so they keep working.
    • packages/client-runtime/src/state/pullRequestDiffHttp.ts → fetchEnvironmentPullRequestDiff builds the request with buildEnvironmentAuthHeaders(prepared.httpAuthorization, "POST", url, signer), i.e. Authorization: DPoP <accessToken> + a fresh proof — but the access token is whatever PreparedConnection.httpAuthorization captured at connect time.
    • packages/client-runtime/src/connection/remote.ts only checks expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS when (re)building the connection. Nothing refreshes the token before an HTTP call, and there is no retry on EnvironmentAuthInvalidError.
    • Server side, apps/server/src/auth/EnvironmentAuth.ts: sessions.issue({ … ttl: hours(1) }) for DPoP sessions; an expired session fails authenticateToken → ServerAuthInvalidCredentialError → failEnvironmentAuthInvalid("invalid_credential") → HTTP 401 with the message above.

    So on /api/auth/session the lapse is hidden behind a 200 (this issue); on /api/pull-requests/diff it is a hard 401 that the UI shows verbatim. The fix should be the same in both places: refresh the DPoP access token before HTTP calls once it is inside the expiry margin, and retry once on EnvironmentAuthInvalidError, rather than only fixing the /api/auth/session response shape.

    Steps to reproduce

    1. Link an environment to T3 Connect; connect to it from T3 Code desktop on another machine (macOS here).
    2. Leave the connection up for > 1 hour without it dropping.
    3. Open any linked pull/merge request: Summary and Timeline load; Code tab → 0 files + The environment rejected this client's credentials (invalid_credential).
    4. Reconnect (or restart the client): the Code tab loads for another hour.

    Version

    Environment server 0.0.34 (desktop-managed, Linux x64); Mac client T3 Code 0.0.34; also observed with the iOS client on the same environment (1-hour DPoP sessions as well).

    Environment

    Server host: Linux x64 (CachyOS, kernel 7.2), Node 26.7. Client: macOS, Electron desktop 0.0.34, via managed relay.

    Evidence

    auth_sessions in the environment's state.sqlite (token columns not read; timestamps UTC):

    subject method client issued_at expires_at last_connected_at
    desktop-bootstrap bearer-access-token T3 Code Desktop (local) 22:12:00 +30 days 22:12:01
    cloud-connect dpop-access-token Electron desktop, MacIntel, 0.0.34 23:45:00 00:45:00 23:45:01
    cloud-connect dpop-access-token Electron desktop, MacIntel, 0.0.34 22:14:30 23:14:30 22:14:32

    The screenshot with the error was taken at 00:55 UTC — ten minutes after the Mac's token expired, with no newer Mac session issued (the socket from 23:45:01 was still up). DPoP proof verification itself is healthy on this server: verified-proof records from another client continued at 00:30–00:43 UTC, so this is not the clock-skew case from #5332.

    Related issues

    #8326 (this one): identical mechanism, reported via /api/auth/session → settings panel. #5332 / #7170 / #5712: same EnvironmentAuthInvalidError text but different causes (clock skew, token exchange, relink).

    Fix applied or workaround

    None persistent. Reconnecting the client mints a new 1-hour token and the Code tab loads until it expires again. The local desktop on the environment host is unaffected (30-day bearer session).

    Filed by

    claude (Claude Fable 5) following the t3 triage playbook (t3 triage 0.0.35 was used to generate the context file; the interactive agent launch was not used).

  2. psicilia5179 commented on Sep 1, 2026

    @psicilia5179
    Author

    Thanks @eugkhp, this is excellent evidence and belongs in this issue. It confirms the same underlying lifecycle bug on a second, higher-impact HTTP path: the WebSocket remains connected after the one-hour DPoP access token expires, while later HTTP requests continue using the token captured when the connection was prepared.

    The different symptoms come from the endpoint contracts:

    • /api/auth/session collapses the expired credential into 200 authenticated:false.
    • /api/pull-requests/diff returns the expected 401, causing the PR/MR Code tab to fail even though socket-backed Summary and Timeline still work.

    This also clarifies the fix scope: changing only the session response would improve the error classification but would not restore diff loading. The client should refresh the DPoP token before HTTP requests when it is near expiry and retry once after EnvironmentAuthInvalidError. The server’s session response can then be corrected separately so expired and absent credentials are distinguishable.

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