Apply limits and authorization to each unit of work, not only to the HTTP request that carries it. For GraphQL, accept only registered queries where you can, and cap tokens, aliases and estimated cost before any resolver runs. For WebSockets, check Origin before the upgrade and authorize every message against current permissions. For gRPC, set message-size and stream limits and make the deadline cancel every task the handler starts.
- Depth limits do not bound width: in this guide's cost model three aliases of a 100-row field cost 603 units, over a 500-unit budget.
- Trusted documents, also called persisted queries, are the strongest GraphQL control: production accepts only queries registered from your own client build.
- Aliases let one request try many passwords, so cap aliases and count the operations inside a request, not just the requests.
- Browsers send cookies with cross-site WebSocket handshakes; check
Originbefore the upgrade to stop cross-site WebSocket hijacking (CSWSH). - Authorize every WebSocket message against current roles: a socket opened by an editor must stop editing when the role is removed.
- grpc-js accepts 4 MiB messages and sends messages of any size by default; set both limits and propagate cancellation into child work.
| Setting | Value | Source |
|---|---|---|
| GraphQL documents in production | Registered (trusted) documents only, looked up by hash | GraphQL: security |
graphql-js parse maxTokens | Set a cap such as 2000; there is no limit by default | graphql-js 17 parser source |
| GraphQL introspection | Off in production unless the API is meant for other parties | OWASP GraphQL |
ws maxPayload | The largest valid message, such as 64 KiB; the default is 100 MiB | ws: WebSocketServer options |
grpc-js grpc.max_receive_message_length | The largest valid request; the default is 4 MiB | grpc-js: supported channel options |
grpc-js grpc.max_send_message_length | Set explicitly; the default is unlimited | grpc-js: supported channel options |
- Run
node --test graphql-admission.test.mjs: Q1 passes, Q2 fails with cost 603, and Q3 and 20 aliased logins are rejected. - Open a WebSocket with
Origin: https://evil.exampleand a valid session cookie; expect 403 before the upgrade. - Remove a user's role while their socket is open and confirm that the next edit is refused.
- Send a 2 MiB gRPC request to a service limited to 1 MiB and expect
RESOURCE_EXHAUSTED; call with a 300 ms deadline and confirm the child task stopped. - Confirm that your HTTP/2 servers and proxies include the October 2023 fixes for Rapid Reset (CVE-2023-44487).
- Applies to
- graphql-js 17.0.2 (tested)
- ws 8.21.3 (tested)
- @grpc/grpc-js 1.14.5 (tested)
GraphQL, WebSocket and gRPC operation-level controls. These protocols need separate message and authorization tests.
- Part of
- Learning path: Runtime and cloud defense (step 8 of 13). Workflows: Fix a finding, Secure a feature, Test an application
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Calculate breadth-driven GraphQL work at a fixed depth
- Apply authorization to each WebSocket action after role change
- Trace a gRPC deadline into child work
Before you start
Backend developers and API platform engineers who run GraphQL, WebSocket or gRPC services
- Distinguish a GraphQL resolver, WebSocket message and gRPC method from their HTTP transport.
Refresh first: Bound API work, not just request counts
Go to the guided practiceA GraphQL request can trigger many resolver operations, a WebSocket handshake can establish hours of messages, and a gRPC stream can carry repeated work after initial metadata is accepted. A rate limit on HTTP request count or authorization performed only at connection establishment misses these later units of work.
Define the security unit for each operation: selected objects and fields, message actions, stream items, bytes, duration, concurrent work and downstream calls. Include cancellation and identity changes. A connection created before logout must not retain unlimited authority simply because its transport remains open.
A GraphQL depth limit measures nesting, not total work. In this explicit cost model each reports alias costs one setup unit plus two units per requested row. A query with three aliases of 100 rows therefore costs 3 × (1 + 2 × 100) = 603 units, even if all aliases have the same shallow shape. With a 500-unit budget it must be rejected before execution. A two-alias query of 100 rows costs 402 and fits this cost limit, although authorization and other resource limits still apply.
The formula is a teaching model, not a universal GraphQL server setting. In a real schema the estimate must account for expensive fields, resolver fan-out, pagination and repeated selections as the actual execution engine treats them. Compare estimated work with observed resolver/database/provider work and revise weights. A syntactic limit that allows an unexpectedly expensive field offers little protection against the resource that field consumes.
For a WebSocket, connection authentication and message authorization occur at different times. The supplied connection opens while user U is an editor. After the role is revoked, an edit message must consult the current authorization contract or a bounded, explicitly defined credential state. Keeping a socket alive does not grant indefinite editing rights. Define behavior for logout, expiry and revocation and test an already-open connection.
For gRPC, a caller’s deadline bounds its willingness to wait. The handler and spawned work must observe cancellation and stop or reconcile appropriately. If a subprocess continues for 40 seconds after the RPC ends at two seconds, the response timeout has not bounded that subprocess. Observe the child task and released resources, not just the client’s DEADLINE_EXCEEDED result.
COST MODEL: sum over aliases of (1 + 2 × requested_rows)
GraphQL budget: 500 units; requested_rows integer 1..100
Q1: 2 aliases, each requesting 100 rows
Q2: 3 aliases, each requesting 100 rows
Q3: 1 alias requesting 101 rows
WebSocket policy: edit requires current editor role on every edit action
gRPC handler: 2 s deadline; child work must observe cancellationValidate inputs and apply object and field permissions at the relevant resolver/data-access boundary. Reuse the application authorization policy rather than assuming the top-level query check covers every nested object. Batch loading and caches must preserve tenant and audience context.
Limit depth where useful, but also measure breadth, aliases, batching, pagination and expensive fields; depth alone cannot bound a wide query. The strongest control is trusted documents, also called persisted queries: the client build registers its operations, and the server executes only documents it can look up by hash, so an attacker cannot send new query shapes at all. Aliases deserve their own cap because they also defeat request-count limits: twenty aliased login mutations in one request are twenty password guesses. Disabling introspection is an exposure decision, not a replacement for these controls. The API resource limits guide at /domains/runtime/api-security/resource-consumption-limits/ applies the same budgeting to other endpoints.
The module below checks a document before execution with graphql-js 17: it accepts only registered documents, stops parsing after 2,000 tokens, and runs the standard validation rules plus one that applies this guide's cost model and an alias cap of ten. Its tests reproduce the exercise exactly: Q1 passes at 402 units, Q2 is rejected at 603, Q3 fails the 1 to 100 row contract, and twenty aliased login attempts are rejected. The rule counts fields where they appear, so a fragment spread many times needs expanding; maintained libraries such as GraphQL Armor or graphql-query-complexity handle fragments, variables and directives.
// Pre-execution limits for GraphQL (graphql-js 17): trusted documents, a token cap, an alias cap
// and a cost budget of 1 + 2 x rows per reports field, all checked before any resolver runs.
import {createHash} from 'node:crypto';
import {GraphQLError, Kind, parse, specifiedRules, validate} from 'graphql';
const trusted = new Map(); // sha256 -> query text, registered from the client build
export function trust(query) {
const id = createHash('sha256').update(query).digest('hex');
trusted.set(id, query);
return id;
}
const limits = ({budget, maxAliases}) => context => {
let cost = 0;
let aliases = 0;
return {
Field(node) {
if (node.alias) aliases++;
if (node.name.value !== 'reports') return;
const first = node.arguments.find(arg => arg.name.value === 'first')?.value;
const rows = first?.kind === Kind.INT ? Number(first.value) : NaN;
if (rows >= 1 && rows <= 100) cost += 1 + 2 * rows;
else context.reportError(new GraphQLError('reports(first:) must be a literal from 1 to 100', {nodes: node}));
},
Document: {
leave() {
if (aliases > maxAliases) context.reportError(new GraphQLError(`${aliases} aliases exceed ${maxAliases}`));
if (cost > budget) context.reportError(new GraphQLError(`query cost ${cost} exceeds ${budget}`));
},
},
};
};
export function admit(schema, documentId) {
const query = trusted.get(documentId); // production accepts registered documents only
if (!query) return {errors: [new GraphQLError('unknown document')]};
const document = parse(query, {maxTokens: 2000});
const errors = validate(schema, document, [...specifiedRules, limits({budget: 500, maxAliases: 10})]);
return errors.length ? {errors} : {document};
}For WebSockets, validate the browser Origin before upgrading. Browsers attach the user's cookies to a WebSocket handshake started by any site, and the same-origin policy does not protect the connection, so without an Origin check a malicious page can open a socket as the victim; this is cross-site WebSocket hijacking (CSWSH). Then authenticate the connection, authorize individual messages, bound message size and frequency, parse with an explicit schema, and define behavior after session expiry or revocation. Origin checks complement authentication; non-browser clients do not have to obey browser-origin conventions. WebRTC signaling over a WebSocket also needs room authorization, server-assigned peer IDs and relay limits (/domains/appsec/secure-features/webrtc-security/).
For gRPC, validate metadata and method permissions, set deadlines and propagate cancellation into downstream work. Bound inbound/outbound message sizes, stream duration and concurrent streams using the supported library and server configuration. A cancelled RPC does not automatically stop every application-created task; the handler must cooperate. Protect transport and service identity independently of the method-level business rule.
The gateway below, using ws 8, rejects a handshake from another origin with 403 before the upgrade, checks the current role for every edit, and caps messages at 64 KiB instead of the library default of 100 MiB. In its tests a socket from https://evil.example was refused, an edit succeeded, the same socket was refused the next edit after the role was removed, and a 65 KiB message closed the connection with code 1009. The tests also found that without an error handler on each socket, the oversized message raised an uncaught exception that would crash a real server.
// WebSocket gateway (ws 8): check Origin before the upgrade to stop cross-site WebSocket
// hijacking, cap message size, and authorize every edit against the user's current role.
import http from 'node:http';
import {WebSocketServer} from 'ws';
const ALLOWED_ORIGINS = new Set(['https://app.example']);
export function createGateway({authenticate, canEdit}) {
const server = http.createServer((req, res) => res.writeHead(404).end());
const wss = new WebSocketServer({noServer: true, maxPayload: 64 * 1024});
server.on('upgrade', (req, socket, head) => {
const user = authenticate(req); // from the session cookie or token, as for HTTP requests
if (!ALLOWED_ORIGINS.has(req.headers.origin) || !user) {
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
return;
}
wss.handleUpgrade(req, socket, head, ws => {
ws.on('error', () => ws.terminate()); // oversized or malformed frames; unhandled, this crashes the process
ws.on('message', data => {
let message;
try {
message = JSON.parse(data);
} catch {
return ws.close(1007, 'invalid JSON');
}
if (message.type !== 'edit') return ws.close(1008, 'unsupported message');
// Look up authority now, not at connect time: roles can change while the socket is open.
const allowed = canEdit(user, message.document);
ws.send(JSON.stringify({type: allowed ? 'saved' : 'forbidden', document: message.document}));
});
});
});
return server;
}grpc-js, like other gRPC implementations, rejects received messages over 4 MiB by default but places no limit on the size of messages it sends. The server below sets both, caps concurrent streams per HTTP/2 connection, and turns the cancelled event, which fires when the deadline passes or the client goes away, into an abort signal for the work it started. In the tests a 2 MiB request failed with RESOURCE_EXHAUSTED against a 1 MiB limit, and a call with a 300 ms deadline returned DEADLINE_EXCEEDED while the five-second child task stopped within the same second instead of running on.
Concurrency limits alone did not stop HTTP/2 Rapid Reset (CVE-2023-44487): in 2023 attackers opened and immediately reset streams, which never counted against the stream limit, and produced record request rates. It was fixed in October 2023 releases of HTTP/2 servers, proxies and libraries, including nginx, Envoy, Go and Node.js, so keep those components current and rate-limit stream resets where your server supports it. The layer 7 DDoS guide at /domains/runtime/availability/l7-ddos-resilience/ lists the HTTP/2 attacks that followed and their fixes.
// gRPC server (grpc-js): explicit message-size and stream limits, and a deadline whose
// cancellation reaches the work the handler started.
import grpc from '@grpc/grpc-js';
import protoLoader from '@grpc/proto-loader';
const proto = grpc.loadPackageDefinition(protoLoader.loadSync(new URL('./report.proto', import.meta.url).pathname));
export function startServer(buildReport, port = 0) {
const server = new grpc.Server({
'grpc.max_receive_message_length': 1024 * 1024, // the default is 4 MiB
'grpc.max_send_message_length': 4 * 1024 * 1024, // the default is unlimited
'grpc.max_concurrent_streams': 100, // per HTTP/2 connection
});
server.addService(proto.reports.Reports.service, {
async Build(call, callback) {
const work = new AbortController();
call.on('cancelled', () => work.abort()); // deadline passed or client went away
try {
callback(null, {rows: await buildReport(call.request, work.signal)});
} catch (err) {
callback({code: grpc.status.CANCELLED, message: String(err)});
}
},
});
return new Promise((resolve, reject) =>
server.bindAsync(`127.0.0.1:${port}`, grpc.ServerCredentials.createInsecure(), (err, bound) =>
err ? reject(err) : resolve({server, port: bound}),
),
);
}
export const client = port => new proto.reports.Reports(`127.0.0.1:${port}`, grpc.credentials.createInsecure());Count resolver calls, database queries and work units while exercising aliases, large batches, repeated stream messages and a disconnected client. Verify that rejected work never reaches a sensitive sink and that cancellation frees the resource. Test role revocation on an existing connection and a legitimate reconnect. For legacy XML/SOAP integrations, separately review parser capabilities, signature semantics and resource bounds rather than applying JSON-only assumptions.
The tests below check the GraphQL module against the exercise queries and an alias brute-force attempt. They pass on Node.js 26.3.0 with graphql-js 17.0.2.
import test from 'node:test';
import assert from 'node:assert/strict';
import {buildSchema} from 'graphql';
import {admit, trust} from './graphql-admission.mjs';
const schema = buildSchema(`
type Report { id: ID! }
type Query { reports(first: Int!): [Report!]! }
type Mutation { login(user: String!, password: String!): Boolean }
`);
const aliased = (n, rows) => `{ ${Array.from({length: n}, (_, i) => `r${i}: reports(first: ${rows}) { id }`).join(' ')} }`;
const check = query => admit(schema, trust(query));
const messages = result => (result.errors ?? []).map(e => e.message);
test('Q1: two aliases of 100 rows cost 402 and pass', () => {
assert.deepEqual(messages(check(aliased(2, 100))), []);
});
test('Q2: three aliases of 100 rows cost 603 and are rejected', () => {
assert.deepEqual(messages(check(aliased(3, 100))), ['query cost 603 exceeds 500']);
});
test('Q3: 101 rows breaks the input contract', () => {
assert.match(messages(check(aliased(1, 101)))[0], /literal from 1 to 100/);
});
test('alias brute force: 20 login attempts in one request are rejected', () => {
const brute = `mutation { ${Array.from({length: 20}, (_, i) => `a${i}: login(user: "u", password: "p${i}")`).join(' ')} }`;
assert.deepEqual(messages(check(brute)), ['20 aliases exceed 10']);
});
test('unregistered documents and oversized documents never reach validation', () => {
assert.deepEqual(messages(admit(schema, 'f'.repeat(64))), ['unknown document']);
assert.throws(() => check(`{ ${'reports(first: 1) { id } '.repeat(300)} }`), /more than 2000 tokens/);
});A query cache or data loader can accidentally mix tenant contexts even when the resolver checks a top-level identity. Scope keys and lifetime to all authorization-relevant inputs; test the same object identifier under two tenants in alternating order.
Closing every connection on any authorization change may be a valid operational choice, but it still needs race handling and a reconnect policy. A stale message already queued for processing must pass the action’s authorization boundary. Similarly, cancelling a stream cannot undo a business effect already committed; expose the operation’s durable status when that distinction matters.
These outcomes belong to the worked example; the tests on this page reproduce the GraphQL, WebSocket and gRPC rows against small reference implementations.
| Case | Expected result | What it establishes |
|---|---|---|
| Q1 | 402 units; cost check passes | Legitimate breadth retained |
| Q2 | 603 units; reject before resolver work | Breadth contributes to cost |
| Q3 | Reject invalid row limit | Input contract precedes calculation |
| Edit after editor role removal | Deny the message | Long-lived connection has no perpetual grant |
| Child still runs after RPC deadline | Failure of cancellation contract | Client status alone is insufficient |
Guided practice
Calculate the supplied query costs and review the event trace. No GraphQL, WebSocket or gRPC server is required for this evidence exercise.
Supplied scenario material
10:00 U connects with editor role.
10:01 administrator removes U’s editor role.
10:02 U sends edit(document=7) on the existing socket; handler uses role cached at connect and commits.
10:03 RPC R begins with a 2-second deadline and spawns task C.
10:03:02 client receives deadline exceeded; C continues.
10:03:40 C releases its worker slot.1. Calculate before accepting
Compute Q1 and Q2, then explain the separate reason Q3 fails.
Expected result and reasoning
Q1=2×201=402; Q2=3×201=603. Q1 fits and Q2 exceeds 500. Q3 violates the maximum 100-row input contract even though its formula alone would be below the total budget.
2. Find the authorization moment
Identify the incorrect assumption behind the committed edit.
Expected result and reasoning
The handler treated a role observed at connection establishment as current authority at 10:02. Under the supplied policy the edit must be denied after role removal; the transport may remain authenticated while this action is forbidden.
3. Measure cancellation
Calculate how long C ran beyond the deadline and state what to instrument.
Expected result and reasoning
C continued for 38 seconds beyond the two-second deadline. Observe cancellation received by C, its stopping/cleanup outcome, worker-slot release and downstream calls. The client error does not show these events.
4. Keep positive controls
Specify one successful case alongside each rejection.
Expected result and reasoning
Keep Q1; an edit by a user who still has editor authority; and an RPC finishing within its budget with resources released. Otherwise a blanket rejection could masquerade as a correct repair.
The costs and trace are worked by hand; the GraphQL, WebSocket and gRPC modules on this page show the same decisions in tested code.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Repair a real proxy and shared cache
Reproduce a private-response leak, repair cache policy and verify HTTP framing with real origin/proxy servers and raw socket requests.
In the extracted folder, run:
node observe.mjs
node --test exercise.test.mjs12 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
Part of the learning path Browser and API boundaries
Actual HTTP/1.1 loopback traffic and a narrow cache contract; it is not a full HTTP cache or a test of your production intermediary chain.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP GraphQL
- OWASP WebSocket Security
- gRPC deadlines
- GraphQL: security
- ws: WebSocketServer options
- grpc-js: supported channel options
- CWE-1385: missing origin validation in WebSockets. CWE version 4.20.
- CISA: HTTP/2 Rapid Reset vulnerability (CVE-2023-44487). 10 October 2023.
- GraphQL Armor
- graphql-js 17 parser source
Reference information
- Tags
- Browser securityCross-site request forgery (CSRF)Denial of service and resource limitsAPI security
- SDLC phase
- DevelopmentMaintenance
- ASVS 5.0.0 requirements
- v5.0.0-4.3.1 · v5.0.0-4.3.2 · v5.0.0-4.4.1 · v5.0.0-4.4.2