Repository navigation
feat(stream): re-validate JWT / enforce token expiry on long-lived SSE connections #239
Description
Activity
- addedenhancementNew feature or requestNew feature or requestarea/apiHTTP handlers, routing, middlewareHTTP handlers, routing, middlewarearea/policyAccess control policies (Hasura-style)Access control policies (Hasura-style)securitySecurity-sensitive issue or fixSecurity-sensitive issue or fix
on Jun 4, 2026 - addedbreaking-changeBreaking change to public API, CLI, or configBreaking change to public API, CLI, or config
on Jun 4, 2026 - addedarea/streamingSSE / live-query delivery path (/v1/stream)SSE / live-query delivery path (/v1/stream)area/authAuthentication: tokens, JWT/JWKS, keys, token expiry/revocationAuthentication: tokens, JWT/JWKS, keys, token expiry/revocation
on Aug 3, 2026 Confirming this by code-read against current
main, with the exact location in case it helps whoever picks it up.internal/api/stream.goresolves the caller's role once, at connection setup:role := auth.RoleFromContext(r.Context()) // stream.go:48 ... h.Hub.Add(topic, role, sub) // stream.go:82
and then enters the
for { select { … } }delivery loop, which never revisits it. The connection is pinned to whatever role and token state existed atonopen, for as long as the client holds the socket open — noexpcheck, no re-validation, no policy re-read.One consequence worth folding into the scope: this defeats short-TTL tokens as a revocation strategy. A deployment that deliberately mints short-lived tokens — so that the revocation window is bounded by the TTL — gets that property on queries and not on streams. A single long-lived SSE connection outlives every token rotation, indefinitely. That's a surprising asymmetry if you've reasoned about your exposure window from the token lifetime, and it's invisible from the outside.
Cheap first step: even without timer-based re-validation, a configurable maximum connection lifetime — close the stream, let the client's
EventSourceauto-reconnect, and re-run the full auth path on reconnect — would bound the exposure with very little machinery, and it reuses the reconnect behavior that already exists for slow-consumer disconnects. The full re-validate-in-place version could follow without the interim being wasted work.Related: #427 (introspection mode) would make this sharper still — a stream that never re-checks would silently ignore a revocation the introspection endpoint had already reported.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsBacklog
Area: streaming · auth — security gap · from Eric's notes
Expected: a long-lived SSE connection reflects the token's lifetime and current permissions — an expired/revoked token, or a policy change, eventually stops or re-authorizes the stream.
Actual: the JWT is validated once when the stream opens; after that the connection serves indefinitely. There's no periodic re-validation, no mid-stream
expenforcement, and a policy change isn't re-evaluated for an already-open subscription. A token that expires (or is revoked) an hour into a stream keeps delivering.Scope: periodic re-check of the token (expiry + signature) and/or policy re-evaluation on a timer for open SSE connections; close or downgrade the stream when it no longer authorizes.
Related: auth-hardening epic #228 (exp, revocation); SSE keepalive #226 (same handler).
From Eric's notes scratchpad; triaged 2026-06-04.