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.
GET /graphql (Upgrade: websocket)101 Switching Protocolsconnection_init { Authorization: ... }subscribe { ... }What you need
Section titled “What you need”- 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).
1. Know where the edge stops
Section titled “1. Know where the edge stops”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
Authorizationheader 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-TimestampandGP-Signature, - is matched against your client profiles. With
client.unknown = "reject", a handshake that matches no profile is refused with 403 andUNKNOWN_CLIENT, and one below a profile’smin_versionwithCLIENT_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 theUser-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 feature | Inside the tunnel |
|---|---|
| Authentication | Handshake only. A credential sent as a frame is never seen, so nothing at the edge can verify it. |
| Cache policy, surrogate keys, invalidation | Do not apply. Nothing that flows through a tunnel is cacheable. |
| Request signing | Covers the handshake. Individual frames are not signed, and the connection outlives the signature’s timestamp. |
| Persisted queries | Enforced 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 block | Do not apply. An operation sent as a frame is never parsed, so request., max_, max_ and introspection do not limit it, and neither does a client profile’s lower value. |
| Request size | Covers 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. |
| Telemetry | One 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.
2. Verify the signature on the handshake
Section titled “2. Verify the signature on the handshake”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
maxPayloadoption of thewslibrary. 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
subscribemessage. 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.
Check that it works
Section titled “Check that it works”Open a connection through GraphPilot and send a connection_init with a token that is not
valid:
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.
Pitfalls
Section titled “Pitfalls”- A
101in your telemetry is not your origin’s answer. The handshake is recorded as101as 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/mixedresponse 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.