Skip to content

feat(subscriptions): connectionExpiry and keepAlive options for useSubscriptionsServer - #149

Merged
cuzzlor merged 5 commits into
mainfrom
feat/close-on-token-expiry
Oct 15, 2026
Merged

cuzzlor merged 5 commits into
mainfrom
feat/close-on-token-expiry

Conversation

@cuzzlor

@cuzzlor cuzzlor commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

useSubscriptionsServer verifies a connection's token when the connection opens, and again each time the client subscribes on it. That leaves three gaps:

  • A connection has no lifetime of its own. A subscription that is already running is never checked again, so it can stream indefinitely after its token expires or its session is revoked. Yet the next subscribe on that connection, after the token expires, closes the whole connection, and graphql-ws runs every subscription of a client over one connection. Neither behaviour is a deliberate lifetime.
  • Token lifetime is not always session lifetime. Some deployments authenticate the upgrade request with a short-lived token that proves a longer session, such as an assertion that a proxy adds and refreshes for each request. Closing connections when that token expires would cut subscriptions off every few minutes.
  • A refused operation still runs. When the subscribe-time check fails, onSubscribe closes the socket but returns nothing, so graphql-ws goes on to build the context and execute the operation. A mutation sent over the websocket with an expired token runs.

The server's keep-alive is also fixed at graphql-ws's default (a ping every 12 seconds).

Change

Two optional options on useSubscriptionsServer, and a fix:

Option Default Effect
connectionExpiry none (claims) => number | undefined. Given the claims verifyToken returned when the connection opened, it returns the time the connection ends, in milliseconds since the epoch. At that time the connection is closed with 4403 Forbidden, which ends every subscription running on it. A time already past refuses the connection. Until then the client may subscribe again without the token being verified again. Returning undefined keeps the default behaviour.
keepAlive 12_000 Milliseconds between the server's websocket pings, passed to graphql-ws's useServer. 0 turns the pings off.
// End each connection when its token expires
connectionExpiry: (claims) => (claims.exp === undefined ? undefined : claims.exp * 1000)

// A proxy's short-lived assertion proves a longer session: connections last an hour from when it was issued
connectionExpiry: (claims) => (claims.iat === undefined ? undefined : (claims.iat + 3600) * 1000)

How connectionExpiry works:

  • The timer starts in onConnect, after verifyToken. A connection's token cannot change: it comes from the connection params or the upgrade request, both fixed when the connection opens. So within the connection's lifetime, a subscribe needs no second verification.
  • onSubscribe still checks the time, for an operation that arrives just before the timer fires.
  • The timer is cleared on the socket's own close event, not in graphql-ws's onClose. onClose waits for every subscription on the connection to return, and a subscription waiting for its next event can hold that up indefinitely. The timer is unref'd, and a delay beyond setTimeout's limit (about 24.8 days) is timed in steps.
  • A time already past refuses the connection before it is acknowledged. Acknowledging it and then closing it would reset a graphql-ws client's retry count, so the client would reconnect without end.
  • A function that throws refuses the connection, logged as Failed to compute subscription connection expiry.

The fixes.

  • Every refusal in onSubscribe (no token, a token that no longer verifies, an ended connection) now closes the socket and returns a Forbidden error, which stops graphql-ws from executing the operation.
  • onError took the wrong arguments for graphql-ws v6, so it logged the operation id as message and the operation payload as errors. It now logs { id, errors }, and skips the refusals that onSubscribe has already logged.

Reconnecting. graphql-ws clients retry 4403 by default, but a retry succeeds only with a fresh token: connectionParams must be a function that returns a current token, or the upgrade request must be authenticated afresh (a cookie and a proxy, for example). The README and JSDoc say so.

All of this is backward compatible: without connectionExpiry the behaviour is as before, apart from the fix. The version goes from 3.4.0 to 3.5.0.

Things to review

  • A connection can outlive its token. That is the point of returning a later time, and the JSDoc says a revoked session is then noticed only when the connection ends. Revocation faster than that needs the consumer to close connections itself.
  • New log lines, each with the claims in jwtClaimsToLog:
    • Subscription connection expired (info): the timer closed a connection.
    • Subscription connection has expired (warn): an operation arrived after the end.
    • Subscription connection has already expired (warn): a connection was refused at connect.
    • Failed to compute subscription connection expiry (error): the function threw.
  • Changed log line: GraphQL subscriptions server error now carries id and errors, in place of message (the operation id) and errors (the payload). Log queries on the old fields must be updated.
  • Behaviour change in the fix. A consumer that relied on a refused operation running anyway (none should) would see it refused.

Tests

src/subscriptions/server.spec.ts, with a real ws server and a graphql-ws client. The test schema gains a ticks subscription that never ends, a stalls subscription that waits forever after its first event, and a bump mutation that counts its runs.

  • An operation whose token no longer verifies gets 4403 and does not run, and no server error is logged. This test, and the one for an ended connection, fail when onSubscribe returns nothing.
  • An operation error (an unknown field) is logged with its id and errors.
  • connectionExpiry:
    • It is given the claims verifyToken returned.
    • The connection closes with 4403 at the time it returns, after ticks have arrived, and the closing is logged.
    • A time already past refuses the connection before it is acknowledged: no tick arrives, and it is not logged as established.
    • A function that throws refuses the connection, and the failure is logged.
    • Until then a second operation runs and verifyToken is not called again, even when it would now refuse the token.
    • An operation that arrives after the end but before the timer fires (Date.now mocked) gets 4403 and does not run.
    • Returning undefined keeps the default: the token is verified again on subscribe.
    • The timer stops when the client disconnects first, including while a subscription waits for its next event.
  • keepAlive: 50 sends at least 3 pings in 280 ms, and 0 sends none.

Verification

  • npm test: 174 passed. The server tests passed 20 of 20 repeated runs.
  • npm run build (lint, types, rollup and attw): "No problems found".
  • npm run audit: "All good".

Dependency vulnerability fixes (commit 3)

CI's npm run audit failed on four advisories published since #147. All four are in devDependencies, so none reaches consumers.

  • npm audit fix updates proxy-addr, shell-quote and source-map-js within their existing ranges (lockfile only).
  • @graphql-tools/utils (GHSA-7mx3-vvmw-hjmv, prototype pollution in mergeDeep) affects every version up to 12.0.0, and there is no fixed 8.x release. It comes in through graphql-shield → graphql-middleware@6.1.35. An overrides entry pins it to 12.0.1 in this repo's own tree, the first fixed version. It depends on @whatwg-node/promise-helpers@^1 (Node.js 16 or later), whereas 12.0.4 brings in version 2, which needs Node.js 22.15 and this package supports Node.js 20. Overrides do not reach consumers, who supply graphql-shield themselves.
    • No test here exercises graphql-middleware: src/shield.ts only calls rule() and shield(). A manual check of applyMiddleware(schema, shield(...)) on 12.0.1 resolved an allowed field and refused a denied one.

…SubscriptionsServer

closeOnTokenExpiry closes a connection with 4403 Forbidden when the exp of the
token it connected with passes, which ends the subscriptions running on it.
Until now an expired token was refused only on the next subscribe, and running
subscriptions continued. keepAlive sets graphql-ws's ping interval.

@cuzzlor cuzzlor left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dual review summary

Findings: 3 (blocker 0, major 0, minor 3, nit 0)

The change is small and backward-compatible: both options are off or unchanged by default. It also handles the hard cases well: a socket that closes during token verification, delays beyond the setTimeout limit, and unref'd timers. The two reviewers agreed on all three findings and on their severity. The README and JSDoc say a reconnecting client gets a fresh token automatically, but it does not. The expiry timer is cleared in a graphql-ws hook that can run long after the socket closes. And epochSeconds rounds down, which makes the expiry tests flaky.

Findings

  • minor: Docs say a reconnecting client gets a fresh token automatically (confirmed)
  • minor: Expiry timer can fire after the socket has closed, because onClose runs late (confirmed)
  • minor: epochSeconds rounds down, so the expiry tests race the second boundary (confirmed)

Generated by dual-review — Claude + litellm/azure/gpt-5.6-sol.

Comment thread README.md Outdated
Comment thread src/subscriptions/server.ts Outdated
Comment thread src/subscriptions/server.spec.ts Outdated
graphql-ws's onClose waits for every subscription on the connection to return,
so a subscription waiting for its next event could leave the timer running past
a disconnect. The docs now say a retry needs a fresh token, and the expiry tests
no longer round exp down to a whole second.
npm audit fix updates proxy-addr, shell-quote and source-map-js within their
ranges. @graphql-tools/utils (GHSA-7mx3-vvmw-hjmv) has no fixed 8.x release, so
an override moves graphql-middleware's copy to 12.0.4 in this repo's own tree.
Consumers supply graphql-shield themselves and are unaffected.
@cuzzlor
cuzzlor marked this pull request as draft October 15, 2026 10:14
A short-lived token can prove a longer session, such as an assertion a proxy
adds to the upgrade request. Closing at the token's exp would cut subscriptions
off every few minutes, so the consumer now chooses when a connection ends.
Until then the token is not verified again on subscribe.

An operation that fails the subscribe-time check is no longer run: onSubscribe
returned nothing after closing the socket, so graphql-ws still executed it.
@cuzzlor cuzzlor changed the title feat(subscriptions): closeOnTokenExpiry and keepAlive options for useSubscriptionsServer feat(subscriptions): connectionExpiry and keepAlive options for useSubscriptionsServer Oct 15, 2026
@cuzzlor
cuzzlor requested a review from mderriey October 15, 2026 10:23

@cuzzlor cuzzlor left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dual review summary

Findings: 5 (blocker 0, major 0, minor 3, nit 2)

The change is well structured. The onSubscribe fix correctly stops a refused operation from running, and real-socket tests cover the new connectionExpiry and keepAlive options. The main risks are narrow but real. An expiry that has already passed at connect time is acknowledged and then closed, so graphql-ws clients reconnect without limit. Refusals now pass through the existing, mis-typed onError hook, which logs an error-level line for each refusal. The dependency override pulls in a package that requires Node.js 22.15, but the project declares and tests against Node.js 20. The reviewers raised no common findings and did not disagree: each raised different issues.

Findings

  • minor: An expiry already past at connect makes graphql-ws clients reconnect indefinitely (claude-only)
  • minor: Every refusal logs "GraphQL subscriptions server error" at error level, with the operation payload as errors (claude-only)
  • minor: The @graphql-tools/utils override needs a newer Node.js than the project supports (gpt-only)
  • nit: A throwing connectionExpiry is logged as an established connection with a failed token verification (claude-only)
  • nit: The connectionExpiry description can be read as a callback that runs at disconnect (claude-only)

Generated by dual-review — Claude + litellm/azure/gpt-5.6-sol.

Comment thread src/subscriptions/server.ts Outdated
Comment thread src/subscriptions/server.ts
Comment thread package.json Outdated
Comment thread src/subscriptions/server.ts Outdated
Comment thread src/subscriptions/server.ts Outdated
…log refusals once

A connection whose connectionExpiry had already passed was acknowledged and then
closed, which resets a graphql-ws client's retry count, so the client reconnected
without end. It is now refused before the acknowledgement.

onError took graphql-ws v5's arguments, so it logged the operation id as the
message and the payload as the errors. It now logs the id and the errors, and
skips the refusals onSubscribe already logs. A connectionExpiry that throws is
logged as such, and refuses the connection.

The @graphql-tools/utils override is pinned to 12.0.1: later versions need
Node.js 22.15, and this package supports Node.js 20.
@cuzzlor
cuzzlor marked this pull request as ready for review October 15, 2026 10:51
@cuzzlor
cuzzlor merged commit b8cc557 into main Oct 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants