skip to content

How long does a GraphQL subscription's authorization stay valid, and where is it re-checked?

level: seniorimportance: nice to knowfreq 26%

answer

  1. Two clocks that never agree
  2. Identity once, operations many
  3. Three moments, three different questions
  4. Expiry is free, revocation costs a lookup
  5. Terminate and let the client resubscribe

basics

~20 s

For as long as the server chooses — nothing revokes it automatically. Authorization can be checked when the connection is established, again for each subscribe, and again per delivered payload; only the last catches a credential that expires mid-stream.

solid answer

~50 s

There are three distinct moments, and they answer different questions. At **connection establishment** the server learns who the caller is — one identity for the whole socket. At each **subscribe** it decides whether that identity may run this particular operation with these arguments; a connection carries many operations, so a single connect-time check authorizes none of them individually. Per **delivered payload** it decides whether the identity may still see this data. Only the third notices that a fifteen-minute access token has expired under a six-hour dashboard, or that a role was revoked an hour ago. Nothing in the GraphQL specification or in either WebSocket subprotocol says otherwise — there is no expiry semantics on the wire and no automatic termination. The usual policy is to record the credential's expiry at connect time, terminate the stream when it passes, and let the client reconnect with a fresh one.

code

pseudocode · 14 lines
pseudocode
onSubscribe(connection, subscriptionId, operation):
    credential = connection.credential
    if credential.expiresAt <= now():
        closeConnection("credential expired")
        return
    if not authorize(credential, operation):
        terminateSubscription(subscriptionId, "forbidden")
        return
    stream = start(operation)
    stream.onEvent(event ->
        if credential.expiresAt <= now() or not stillPermitted(credential, event):
            terminateSubscription(subscriptionId, "authorization expired")
        else:
            deliver(subscriptionId, event))

go deeper

for a junior

Be ready to say that one connection can carry many subscriptions, so knowing who the caller is does not by itself mean each subscription they start is allowed.

for a middle

Explain the three checkpoints — connection, subscribe, delivered payload — and why a short-lived credential under a long-lived stream leaves privileged data flowing unless something explicitly re-checks.

for a senior

Show the cost reasoning: expiry checks are free and unconditional, revocation needs a lookup so it goes on a cache TTL or an invalidation signal, and termination must be a signal the client can resubscribe from.

for a principal

Own the exposure window as a stated policy: how long a stream may outlive its credential, whether an in-band refresh is worth its bespoke protocol, and how termination behaviour is standardised so no team raises token lifetime to work around it.

## Three moments, three different questions People talk about "authenticating the subscription" as if it were one act. It is three, and each answers a question the others cannot. **At connection establishment.** The server learns the caller's identity and attaches it to the socket. How the credential physically reaches the server at handshake time is a WebSocket transport concern and not what this question is about; what matters here is that the result is one identity, established once, for a connection that may live for hours. **At each subscribe.** One connection carries many independent operations, started at different times. A connect-time check has authorized *the caller*, not *the operations* — it cannot have, because none of them existed yet. Deciding whether this identity may run this document with these arguments has to happen when the operation arrives. In a warehouse inventory graph, a picker's socket is legitimately authenticated and their subscription to every purchase-order cost line is legitimately refused. **Per delivered payload.** Even an authorized subscription streams data that keeps changing. Whether the identity may see *this* event is a separate question from whether it could subscribe, and it is the only check positioned to notice that something changed since. ## The two clocks that do not match Here is the mismatch that makes this a real security question rather than a design preference. Access credentials are deliberately short-lived — fifteen minutes is a common figure. Subscriptions are deliberately long-lived: an operations dashboard on a wall screen holds one for a whole shift. Nothing reconciles those clocks by itself. The socket does not notice that a token's expiry passed. The subprotocols carry no expiry semantics — no field for it, no message meaning "your credential lapsed", no defined close reason for it. The GraphQL specification does not mention authorization at all, let alone its lifetime. So the default behaviour of a naive server is that a subscription authorized once at connect time keeps streaming privileged data indefinitely, long after the credential that justified it stopped being valid. That is the gap. ## Expiry is not revocation Two distinct failure modes, and they cost differently to detect. **Expiry** is a timestamp the server already holds from the credential it validated at connect time. Checking it is free: compare against the clock. No lookup, no network call. **Revocation** — a role removed, an account disabled, an item moved out of the caller's scope — is invisible in the credential. The credential still says what it always said. Catching revocation requires consulting current state, which is a lookup per check. That asymmetry drives the policy. Checking expiry on every event is cheap enough to be unconditional. Checking revocation on every event, for every subscriber, on a stream firing thousands of times an hour, is not — so it is usually done on a short cache TTL, or on a periodic sweep, or driven by an invalidation event when permissions change. ``` onSubscribe(connection, subscriptionId, operation): credential = connection.credential if credential.expiresAt <= now(): closeConnection("credential expired") return if not authorize(credential, operation): terminateSubscription(subscriptionId, "forbidden") return stream = start(operation) stream.onEvent(event -> if credential.expiresAt <= now() or not stillPermitted(credential, event): terminateSubscription(subscriptionId, "authorization expired") else: deliver(subscriptionId, event)) ``` ## The three workable policies **Terminate at expiry.** Record `expiresAt` when the credential is validated, and when it passes, end the subscriptions and close the connection. Simple, provably bounded, and it makes the maximum exposure window equal to the credential lifetime. The cost is a visible interruption the client must handle. **Accept a refreshed credential in-band.** Let the client send a new credential over the existing connection and re-evaluate. This keeps long streams alive without reconnecting, but be clear in an interview that it is an *application-level convention*: neither subprotocol defines such a message, so both ends must agree on it, and the server must re-authorize existing subscriptions against the new credential rather than merely storing it. **Re-authorize per event.** The strongest, and the only one that catches revocation rather than expiry. Because the cost is multiplied by every subscriber and every event, it is worth pushing the check into the same data access that produces the payload rather than adding a separate call. ## Making termination survivable Whichever you choose, the client experience decides whether the control is actually deployable. Terminate with a distinguishable signal so the client can tell "your credential lapsed, get a new one and resubscribe" apart from "the server is broken", and design the client to treat that as a reconnect-and-resync rather than an error to display. If termination looks like a crash, someone will raise the credential lifetime to make it stop, and the control will have made things worse. Cheap operational touch: log the identity, the subscription, and the reason on every authorization-driven termination, so the difference between an expiry storm and an actual attack is visible.

  • Why is authorizing once when the connection is established not enough?
    Because it authorizes an identity, not the operations. A single connection carries many independent subscriptions started at different times, and none of them existed when the connection was established. The check that matters — may this identity run this document with these arguments — can only happen when the operation arrives, which is why per-subscribe authorization is the minimum.
  • What is the difference between a credential expiring and a permission being revoked, in this context?
    Expiry is a timestamp the server already holds, so checking it costs nothing and can run on every event. Revocation leaves the credential unchanged, so detecting it requires consulting current permission state — a lookup per check. That asymmetry is why servers check expiry unconditionally but re-check permissions on a short cache TTL or on an invalidation signal.
  • Is there a standard way for a client to hand a refreshed credential to a live GraphQL WebSocket connection?
    No. Neither subprotocol defines a message for it, so any in-band refresh is a convention both ends agree on privately. If you build one, the server must re-authorize the connection's existing subscriptions against the new credential rather than just replacing the stored value — otherwise a refresh silently launders a subscription the new credential would not have permitted.

A building pass gets you through the front door once; it is not a promise that the meeting room you walked into is still yours four hours later.

saying these in an interview costs you the question

  • Believes connect-time authentication authorizes every later subscription
  • Assumes an expiring token automatically ends the stream
  • Claims the subprotocol defines credential expiry semantics
  • Extends credential lifetime so long-lived subscriptions stop breaking
  • Treats expiry and revocation as the same check
  • Stores a refreshed credential without re-authorizing live subscriptions

context