Hunting Bugs in GraphQL Subscriptions
Everyone tests GraphQL queries and mutations. Subscriptions get skipped, because they ride a WebSocket instead of a tidy HTTP request and Burp's Repeater doesn't love them. That neglect is the opportunity — subscriptions are a live data feed, and the auth logic behind them is frequently thinner than the query resolvers next door.
The transport is the first gotcha
Subscriptions run over WebSockets, usually with the graphql-transport-ws (or older subscriptions-transport-ws) protocol. The handshake looks like:
-> connection_init { "authorization": "Bearer ..." }
<- connection_ack
-> subscribe { "query": "subscription { messageAdded { id text } }" }
<- next { data: ... }
The single most important question: where does authentication happen — at connection_init, or per subscription, or nowhere? Each answer hides a different bug.
Missing auth on the socket
Try opening the WebSocket and sending connection_init with no token at all, then subscribing. If the server sends connection_ack and starts streaming data, authentication is missing on the subscription transport even though the HTTP query endpoint is locked down. That mismatch is common when the WebSocket handler was added later and reused less of the auth middleware.
Use a WebSocket-capable client — wscat, a small Node script, or Burp's WebSocket tools — to drive the protocol manually. This is exactly the part scanners skip.
Broken authorization on the stream
Authentication present but authorization broken is the richer vein. With two accounts:
- Connect as user A. Subscribe to something scoped to a resource —
subscription { orderUpdates(orderId: "B-owned-id") { status } }. - Point A's subscription at an ID that belongs to B.
If A receives B's live order updates, chat messages, or notifications, you have real-time IDOR. Subscriptions are especially prone to this because the resolver authorizes the initial subscribe but then pushes every future event on that topic without rechecking whether A should still see it.
That "authorize once, stream forever" pattern also breaks on privilege change: subscribe while you have access, get access revoked, and check whether the stream keeps flowing. If it does, revocation doesn't reach live connections.
Injection and depth still apply
Subscription arguments feed resolvers just like query arguments. If a filter argument builds a database query, test the usual injection payloads through the subscription variables. And subscriptions can be nested and expensive — a deeply nested subscription selection, or thousands of concurrent subscriptions from one connection, can pin server resources. Introspection often reveals the full subscription schema; pull it if it's enabled.
Denial of service, tested gently
WebSocket subscriptions make resource exhaustion easy: open many connections, subscribe to high-volume topics, or craft a subscription whose selection set is huge. You can demonstrate the risk — show that ten connections each subscribing to a firehose topic drive noticeable load — without actually knocking the service over. Never run a real DoS against production; describe the multiplier and stop at proof of concept.
Reporting
Capture the raw WebSocket frames: the connection_init, the subscribe message, and the next payloads showing data you shouldn't receive. For cross-account authorization, show both accounts and the leaked stream from the other user's resource. State severity by what streams — live private messages and order data are high; a public feed you could already see is not.
Recommend authenticating at connection_init and re-checking authorization on every subscribe (and ideally per emitted event), terminating streams when access is revoked, applying depth and complexity limits, rate-limiting connections, and disabling introspection in production.
OWASP's GraphQL Cheat Sheet covers authorization, query cost, and introspection hardening — apply each control to the subscription path specifically, since that's where teams most often forget to.