Skip to content

feat(stream): re-validate JWT / enforce token expiry on long-lived SSE connections #239

Description

@EricAndrechek

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 exp enforcement, 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.

Activity

  1. added
    enhancementNew feature or request
    area/apiHTTP handlers, routing, middleware
    area/policyAccess control policies (Hasura-style)
    securitySecurity-sensitive issue or fix
    on Jun 4, 2026
  2. added
    area/streamingSSE / live-query delivery path (/v1/stream)
    area/authAuthentication: tokens, JWT/JWKS, keys, token expiry/revocation
    on Aug 3, 2026
  3. EricAndrechek commented on Aug 5, 2026

    @EricAndrechek
    MemberAuthor

    Confirming this by code-read against current main, with the exact location in case it helps whoever picks it up.

    internal/api/stream.go resolves 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 at onopen, for as long as the client holds the socket open — no exp check, 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 EventSource auto-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.

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

    area/apiHTTP handlers, routing, middlewarearea/authAuthentication: tokens, JWT/JWKS, keys, token expiry/revocationarea/policyAccess control policies (Hasura-style)area/streamingSSE / live-query delivery path (/v1/stream)breaking-changeBreaking change to public API, CLI, or configenhancementNew feature or requestsecuritySecurity-sensitive issue or fix

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions