Skip to content

Subscriptions and WebSockets

A GraphQL subscription runs over a WebSocket, and a WebSocket is one HTTP request followed by a long conversation that is no longer HTTP. GraphPilot treats those two halves differently, and the difference decides what your origin has to do for itself.

The handshake is an ordinary request and goes through the whole edge pipeline. Everything after it is a tunnel: GraphPilot relays frames between your client and your origin without reading them.

The running example is a GraphQL API that serves subscriptions with graphql-transport-ws on /graphql, used by a browser app that sends its token in connection_init. A WebSocket to a plain HTTP origin goes through the same handshake and the same tunnel; only the GraphQL parts of steps 3 and 5 are specific to subscriptions.

  • An origin that accepts WebSocket upgrades on the path your clients connect to.
  • Your service’s signing key, to verify the handshake; see the signing key.
  • A WebSocket client for the check, such as wscat (npx wscat).

GraphPilot recognizes a handshake by the rules of RFC 6455: a GET carrying Upgrade: websocket and a Connection header whose token list includes upgrade. Such a request

  • passes your auth providers, so an Authorization header sent on the handshake is verified, and a token that does not verify is rejected with 401 before your origin ever sees it (the default; see [auth.rejection]),
  • is signed like every other forwarded request, with GP-Timestamp and GP-Signature,
  • is matched against your client profiles. With client.unknown = "reject", a handshake that matches no profile is refused with 403 and UNKNOWN_CLIENT, and one below a profile’s min_version with CLIENT_VERSION_UNSUPPORTED. A browser cannot set headers on a handshake, so match browser clients on something it does send, such as a cookie, a query parameter or the User-Agent,
  • never touches the cache. An upgrade is always a pass, reported as CACHE_PASS_WEBSOCKET_UPGRADE,
  • is never retried: a handshake that fails reaches the client as a 502,
  • appears once in your telemetry, with status 101.

GraphPilot then hands the connection to your origin and stops interpreting it. Inside the connection:

Edge featureInside the tunnel
AuthenticationHandshake only. A credential sent as a frame is never seen, so nothing at the edge can verify it.
Cache policy, surrogate keys, invalidationDo not apply. Nothing that flows through a tunnel is cacheable.
Request signingCovers the handshake. Individual frames are not signed, and the connection outlives the signature’s timestamp.
Persisted queriesEnforced on HTTP GraphQL requests. An operation sent as a frame is not matched against the registry, so a “persisted queries only” configuration does not constrain what a subscription may execute.
Query depth, document size, selections, introspection blockDo not apply. An operation sent as a frame is never parsed, so request.max_query_depth, max_document_bytes, max_selections and introspection do not limit it, and neither does a client profile’s lower value.
Request sizeCovers the handshake, which has no body. Frames have no size limit at the edge, and a connection has no limit on how many it carries.
TelemetryOne event, for the handshake. Operations and messages inside the connection are not recorded.

This is not a gap to be closed later. A tunnel is what makes a WebSocket work through a CDN at all: the edge cannot buffer, parse or replay a bidirectional stream without breaking the protocol it is carrying.

No setting turns WebSocket upgrades off at the edge. A handshake on any path, GraphQL or not, that passes the checks above, client profiles included, is handed to your origin, even on a service that requires persisted queries or blocks introspection. If your API serves no subscriptions, have your origin refuse the upgrade.

GP-Signature proves the connection was opened through GraphPilot rather than directly against your origin. Verify it on the upgrade request as on any other; see verify the signature on your origin. It says nothing about any frame that follows.

3. Verify the credential from connection_init yourself

Section titled “3. Verify the credential from connection_init yourself”

Browsers cannot set request headers on a WebSocket handshake. That is why clients speaking graphql-transport-ws carry the credential in the connection_init payload instead, the first message after the 101. No auth provider reads it, and the rejection behavior documented for HTTP requests does not apply to it.

Check the signature against your issuer’s key set, the expiry, the issuer, the audience and whatever claims you authorize on. Treat the connection as anonymous until that check passes, and refuse subscriptions on an unauthenticated connection rather than letting them fall through to a default identity.

4. Re-check expiry for the life of the connection

Section titled “4. Re-check expiry for the life of the connection”

A subscription can stay open for hours, far longer than an access token stays valid. Without a periodic check, one token accepted at minute zero authorizes the stream indefinitely. Close the connection when the token expires, or require the client to re-authenticate. 4401 is the close code graphql-ws clients conventionally read as an authentication failure.

5. Apply your own limits to the connection

Section titled “5. Apply your own limits to the connection”

The protections you configure at the edge do not reach inside the tunnel, so the limits on a subscription are the ones your origin sets:

  • Message size. A maximum frame or message size on your WebSocket server, for example the maxPayload option of the ws library. A message over it closes the connection.
  • Operation shape. The same depth, alias or field count and cost limits your GraphQL server applies over HTTP, applied to every operation that arrives as a subscribe message. The edge defaults (depth 10, 4,000 selections, a 64 KiB document) are a reasonable starting point.
  • Introspection. Off in production, as it is at the edge by default; a socket is a way around the edge’s block.
  • Volume. Subscriptions per connection, connections per credential and messages per second.

The full list of what the edge does not check, over HTTP and over a WebSocket, is on hardening a public API.

6. Send the credential on the handshake too, where you can

Section titled “6. Send the credential on the handshake too, where you can”

Clients that can set headers, such as server-to-server consumers and CLIs, may send Authorization on the upgrade request. The edge then verifies it like any other request and rejects a bad token with 401 before your origin is involved. That is a useful additional filter, and it does not replace step 3: as long as any browser client connects to the same endpoint, your origin still has to authenticate the connection itself.

Open a connection through GraphPilot and send a connection_init with a token that is not valid:

Terminal window
npx wscat -c "wss://$GRAPHPILOT_HOST/graphql" -s graphql-transport-ws
> {"type":"connection_init","payload":{"Authorization":"Bearer not-a-token"}}

Your origin should close the connection, with 4401 if it follows the graphql-ws convention. With a valid token it answers {"type":"connection_ack"}. In Insights, the handshake appears once with status 101, and nothing after it. A connection opened straight to your origin, without GP-Signature, should be refused at step 2.

  • A 101 in your telemetry is not your origin’s answer. The handshake is recorded as 101 as soon as GraphPilot has handed it to your origin, even if your origin then refuses the upgrade. Only a handoff that fails is recorded differently, as a bad gateway error. Your origin’s logs say whether it accepted the connection.
  • Subscriptions over HTTP do not work through the edge. GraphPilot delivers an origin response once it is complete, and a Server-Sent Events or multipart/mixed response never completes. Use WebSockets for subscriptions.
  • A persisted-queries-only setup does not cover subscriptions, and neither does a client profile’s persisted_query = "required".
  • GraphPilot secures the door, not the room behind it. Every guarantee about the operations running inside a subscription has to come from your origin.