Hardening a public API
Putting your origin behind GraphPilot moves some of its exposure to the edge. This page lists what GraphPilot refuses on your behalf, and what it does not. The default value of every limit named here is on size limits and request limits; this page says what each control is for.
Which controls run on a cache hit
Section titled “Which controls run on a cache hit”A cache hit is answered without parsing the GraphQL document, so the controls split in two. Those that read the raw request or the caller run on every request, hits included. Those that read the document run only when a request reaches GraphPilot’s GraphQL handling, which is on a cache miss or a pass.
| Control | Runs on |
|---|---|
Request size (max_) | Every request |
| GraphQL on other paths | Every request |
| Credentials (auth providers) | Every request |
| Client profiles, including a profile’s own body limit and its hashes-only rule | Every request |
Persisted queries switched off (PERSISTED_ for a request carrying a hash) | Every request |
| Introspection | Misses and passes |
| Query depth | Misses and passes |
| Document size and selections | Misses and passes |
Operation allowlist (only_) | Misses and passes |
A hit can only serve a response your origin already produced for a request that passed these checks at the time, so skipping them on a hit costs nothing while the configuration stays the same. After you tighten a document control, responses cached under the old rules keep being served until they expire or are purged.
Introspection
Section titled “Introspection”GraphQL only
Blocked by default. An operation selecting __schema or __type is refused with
INTROSPECTION_DISABLED and never reaches your origin; request.introspection = "allowed"
lifts the block. __typename is never blocked; it carries no schema shape. Introspection sent
to a path that is not a GraphQL path is not a way around the block: it is
refused with 404 and NOT_A_GRAPHQL_PATH.
Your own schema stays available to you in the portal, under Service → Schema (schema.graphql holds the deployed file), which reads it from GraphPilot rather than through your service hostname. Blocking introspection costs your team nothing.
Platform probes
Section titled “Platform probes”GraphPilot itself sends two kinds of request through a service’s own hostname: the GraphQL
detection of a new service’s setup ({ __typename } on
/graphql, /api/graphql, /query and /), and the introspection query that reads its schema,
during the setup and for every deployment of a service whose schema
source is Introspect at deploy.
A platform probe is one of two fixed documents GraphPilot sends, the detection query
{ __typename } or the standard introspection query, as a POST with a JSON body to a path with
no query string, signed by GraphPilot for that one service with a timestamp no more than five
minutes off. Any other request carrying GraphPilot’s signature is refused with 403. The edge
recognizes a probe by that signature and document and then:
- lets it through whatever the deployment says about introspection, the GraphQL paths, persisted queries, client profiles and authentication, so introspection blocked here does not block GraphPilot reading your schema;
- still applies the service’s depth and document size limits and the request size limit, so a very tight selection limit can refuse the introspection query;
- never caches it: every probe reaches your origin;
- forwards it like any request: from the edge’s IP ranges, signed with the service’s signing key, with the configured origin timeouts;
- records it apart from your traffic: a probe is not a request unit, not in Insights or the requests log, and does not tick the onboarding “first request”.
Your origin decides whether to answer introspection. GraphPilot holds no credentials for it, so an origin that disables introspection in production or requires an admin secret for it answers the probe with an error, and the setup or the deployment says so.
A probe sent with a wrong or stale signature is refused with 401, and a signed request that is
not one of the two documents (another method, a query string, any change to the document) with
403; neither reaches your origin. The
recognition needs a runtime with this feature: on an older runtime, a probe to a service without a
schema is forwarded as an ordinary request (and counted as one), and a probe to a service with a
schema is treated like any other introspection.
Query depth
Section titled “Query depth”GraphQL only
An operation nested deeper than request.max_query_depth allows is refused with 400 and
QUERY_DEPTH_LIMIT_EXCEEDED, before your origin sees it. The message names the measured depth
and the limit, for example “The operation nests 12 levels deep; this service allows 10.” The
default of 10 sits past what ordinary product queries reach and short of what a nesting attack
needs: a schema with a cycle in it (user { posts { author { posts ... } } }) multiplies your
origin’s work with every level. A subscription sent as an HTTP request is measured like a query or
a mutation; one sent over a WebSocket is not measured at all (see what the edge does not
check).
[request.max_query_depth]client_document = 10 # documents the client sentregistered_document = 10 # documents resolved from your persisted query registryBoth default to 10, and 0 switches either one off. GraphPilot’s own ceiling of 128 levels
still applies with both at 0, because past it the document cannot be parsed safely; see query
depth. The names say where the document came
from, not which protocol carried it: an APQ handshake, where the
client sends the document alongside its hash, is measured as a client document. A client
profile can set a lower ceiling of its own; the
stricter of the two decides.
How depth is counted
Section titled “How depth is counted”- Selection sets are counted, starting at the operation’s own.
{ a }is 1,{ a { b } }is 2. The depth of an operation is its deepest single path; sibling fields do not add up, and an alias is a branch of its own. - Fragments are flattened before measuring, so nesting hidden behind fragment spreads measures the same as nesting written out.
- A type condition is not a level.
... on Postonly says which type the fields beside it belong to. - A field your deployed schema does not define is not counted. The edge does not refuse it
either: unless the edge rewrites the document, your origin receives the text the client sent,
that field included, and its own validation decides. An operation made only of such fields is
refused with
400andINVALID_GRAPHQL_REQUEST.
Limitations
Section titled “Limitations”- A query GraphPilot cannot parse is refused, not measured. A request with a syntax error,
one naming an operation its document does not define, or one whose document holds several
operations and names none is refused with
400andINVALID_GRAPHQL_REQUESTrather than forwarded, because your origin might parse it differently and run what GraphPilot never checked. Valid GraphQL that GraphPilot’s parser does not accept yet (braced unicode escapes such as\u{1F600}in a string) is refused the same way. - A cache hit is not measured again. The check runs only on a miss or a pass, so a response stored while the limit was higher stays servable until it expires or is purged. See which controls run on a cache hit.
- Exempting registered documents is only safe with
preset = "trusted". Underapq, any client may register any document and then send its hash, soregistered_document = 0there is an exemption for anyone willing to make two requests. - Introspection is not measured. Nothing under
__schemais counted, so the standard introspection query measures 1 and GraphiQL, schema downloads and code generation work on the default. For the same reason a deeply nested introspection query is not caught by this limit; only GraphPilot’s own 128-level ceiling applies to it. When introspection is blocked, which is the default, that query is refused anyway.
See query depth for the limit beside the others.
Request size
Section titled “Request size”A body larger than request.max_body_bytes is refused with 413 and PAYLOAD_TOO_LARGE, before
it is parsed and before it reaches your origin. The check reads the bytes actually received, so
an untruthful Content-Length buys nothing. Tighten it to the largest request your clients
actually send; the default is on request body.
Document size and selections
Section titled “Document size and selections”GraphQL only
A document longer than request.max_document_bytes is refused with 400 and
DOCUMENT_SIZE_LIMIT_EXCEEDED before it is parsed, and an operation making more selections than
request.max_selections is refused with 400 and DOCUMENT_SELECTION_LIMIT_EXCEEDED before it
is planned. Both apply to registered documents as much as to documents a client sends, and
persisted_query.max_document_bytes and persisted_query.max_selections can give registered
documents ceilings of their own (see persisted queries).
Selections are counted with every alias and every fragment spread expanded, so alias spam and fragment multiplication are both caught. The defaults are on query document, and the counting rules on how selections are counted.
An allowlist of operations
Section titled “An allowlist of operations”GraphQL only
The strongest control available today. With persisted queries set
to preset = "trusted" (or only_registered = true), the only operations your service runs are
the ones you registered:
- free-form query text is refused outright,
- an unregistered hash is refused,
- and an unreachable registry fails closed rather than falling open.
This turns “what can be executed against my API” from an open question into a list you publish from your build. It is the answer to depth attacks, alias amplification and introspection at once, because none of them can be expressed by an operation you did not ship.
The trade is that clients must be built against a manifest. See choosing a setup. Like the other document controls, the allowlist is checked on a cache miss: a response cached before you switched stays servable until it expires or you revoke the operation, which purges it.
GraphQL on other paths
Section titled “GraphQL on other paths”GraphPilot runs its GraphQL checks only on the paths in
request.graphql_paths. Your origin may answer GraphQL on
other paths as well (Apollo Server’s startStandaloneServer answers on every path), so a request
on any other path that carries a GraphQL operation is refused with 404 and NOT_A_GRAPHQL_PATH
rather than forwarded past those checks. The message names the GraphQL paths, for example
This service serves GraphQL at /graphql. Send the operation there.
On another path, a request carries an operation when it has one of these:
- a JSON body (
application/jsonor any+jsontype) whose top-levelqueryis a GraphQL document that defines an operation, or that carriesextensions.persistedQuery. A JSON array is checked object by object, and a body that repeatsqueryis checked on every copy; - an
application/graphqlbody that is such a document; - an
application/x-www-form-urlencodedbody with such aquery, or with anextensionsparameter holdingpersistedQuery; - the same
queryorextensionsparameter in the URL, on any method exceptOPTIONS.
JSON, form and application/graphql bodies and the query string are inspected, compressed bodies
included. A body that cannot be inspected is refused like one that carries an operation.
A query that is not a GraphQL document is not an operation: {"query":"shoes"},
?query=shoes and an Elasticsearch-style {"query":{...}} reach your origin as before. A
request that is not refused is forwarded exactly as the client sent it, byte for byte.
The refusal applies while at least one GraphQL check is in force: blocked
introspection, the operation allowlist
(persisted_query.only_registered), a client profile with
persisted_query = "required", or a depth, document size or
selection limit on the service or on a profile. The
introspection block and the three limits are on by default, so a service that never changed them refuses. A service
that has switched every one of them off has nothing an operation could go around, and forwards
it. An empty request.graphql_paths, which a deployment without a schema always runs with,
refuses nothing.
It runs on every request, cache hits included, after the request size check, so
a body over max_body_bytes is answered with 413 first. Your logs carry
INGRESS_OFF_PATH_GRAPHQL with form (where the operation was found: json_body,
document_body, form_body or query_string) and protections (the checks it would have gone
around), which tells you which client to point at the GraphQL path. See
codes.
Three forms are not recognized off the configured paths: a multipart/form-data upload carrying
an operations part, the operations sent over a WebSocket after the upgrade, and a persisted
document sent as a bare documentId. Mount your GraphQL server on the configured paths only, and
none of them matters.
Credentials
Section titled “Credentials”- JWTs are verified at the edge, with an explicit algorithm allowlist, and a token that does not verify is refused before your origin is called. See authentication.
- A response computed for one caller is not shared, and where GraphPilot cannot tell it is refused rather than shared. See authenticated requests.
- Requests to your origin are signed, so your origin can refuse everything that did not come through GraphPilot. See request signing.
Refusing callers you did not declare
Section titled “Refusing callers you did not declare”Naming your clients lets the edge refuse one it does not recognize, or one running a build you have retired, before your origin is reached.
[client]unknown = "reject"
[[client.profile]]name = "web-app"when = "auth:idp.client_id == 'web-app'"version = "header:gp-client-version"min_version = "2.1.0"What this is worth depends entirely on what when reads, and the difference is larger than it
looks. Over a header, it is the caller’s own claim about itself: it catches stale
deployments and misrouted traffic, and it stops nobody who has read your public app’s requests.
Over a verified claim, as above, it is reading something the caller could not have invented,
and only then is it a control an attacker has to get past. The difference, and what it costs, is
worked through in matching on a verified
claim.
A floor for a mobile app needs one more step when the app reports its build number after a
hyphen, as in apollographql-client-version: 4.2.0-118. A hyphen marks a prerelease, and a
prerelease sorts below its release, so 4.2.0-118 is older than 4.2.0 and a floor of 4.2.0
would refuse the very build it was written for. Read the release part only:
[[client.profile]]name = "ios-app"when = "header:apollographql-client-name == 'ios-app'"version = "split(header:apollographql-client-version, '-', 0)"min_version = "4.2.0"4.2.0-118 is now read as 4.2.0 and served, and 4.1.3-97 as 4.1.3 and refused with 403
and CLIENT_VERSION_UNSUPPORTED. The request’s Client tab in Insights
shows the version as it was read. Both headers are set by the app itself, so this retires old
builds and stops nobody who sends a newer number. The recipe retiring old mobile app
versions walks through it end to end.
A profile can also give one client a smaller body limit, a shallower depth ceiling, a smaller document size or selection limit, or a hashes-only rule, each stricter than the service-wide setting it narrows. See telling your clients apart.
What the edge does not check
Section titled “What the edge does not check”Stated plainly, because these are the ones an attacker will reach for. Every control above runs on an HTTP request that reaches the edge. What follows is traffic those controls never see, and limits GraphPilot does not have.
| Not checked | What it means for you |
|---|---|
| WebSocket messages | A WebSocket upgrade passes your auth providers and is signed, and is then handed to your origin as a tunnel. Nothing after the handshake is read: no depth, document size, selection or introspection check, no persisted-query rule, no size limit on a message, and no check of a credential sent in connection_. Every subscription operation is checked only by your origin. No setting refuses WebSocket upgrades: a handshake on any path, GraphQL or not, that passes the checks on the handshake itself (your auth providers, for example) is handed to your origin, whatever your persisted-query, introspection and limit settings say. An API that serves no subscriptions refuses the upgrade at its origin. See subscriptions and WebSockets. |
| Requests sent straight to your origin | A request that does not go through GraphPilot is checked by nothing on this page. Until your origin verifies the signature, anyone who learns its address can go around every limit here. |
GraphQL on paths outside request. | The GraphQL checks run only on the configured paths. An operation GraphPilot recognizes on another path is refused while any GraphQL check is in force, but the three forms it does not recognize and every path on a service that has switched all GraphQL checks off reach your origin unchecked. Serve GraphQL only on the configured paths. |
| Bodies forwarded unchecked | A body kind you list in request., such as a multipart upload, reaches your origin without a GraphQL check. Your origin is its only reader. |
| Alias, directive and token counts | There is no limit of their own. An alias counts as a selection, so alias spam is bounded by max_, and the number of tokens only by max_. Directives are not counted, so the document size is the only bound on them. |
| Query cost analysis | Depth and the number of selections are bounded; cost is not. A shallow query over a large list is cheap to write and expensive to answer, and nothing at the edge prices it. |
| Rate limiting | Not available. There is no per-second limit and no cap on requests in flight. A caller can send as many operations as they like. Put a rate limiter in front of GraphPilot, or at your origin for the traffic that reaches it. |
| Field-level authorization | The edge verifies who is calling and never decides what they may see. That stays with your origin. |
CORS or Origin-header allowlisting | The edge never refuses a request because of its Origin, and a preflight is answered by your origin or a cached copy of its answer. Making the edge’s own refusals readable in a browser is a separate matter, below. (Restricting your origin to GraphPilot’s IP ranges is a different thing, covered in origin allowlist.) |
| Payload inspection | Bodies are parsed as GraphQL, not scanned. |
| Client identity, on its own | A client profile matched on a header is friction, not a boundary: the header is whatever the caller sent. Only a profile matched on a verified claim refuses someone who is trying. |
The mitigations that do exist for cost and volume are the allowlist and the document limits above, plus a short freshness lifetime on anything expensive, so that a repeated expensive query is answered from cache rather than from your origin.
One edge case is covered, so it is not on the list:
- A body on a GraphQL path that is not a single JSON operation is not a way around the
document controls. An
application/graphqldocument is checked like any other operation. Anything else (a JSON batch, a multipart upload, text, a missing content type) is refused with415andUNSUPPORTED_GRAPHQL_BODY, and forwarded unchecked only if you list its kind inrequest.forward_unchecked_bodies. A JSON body that repeats a key or nests more than 128 levels deep is refused with400andINVALID_GRAPHQL_REQUESTwhatever that setting lists, and so is aPOST(or aGETwith a body) that also carries GraphQL parameters in its URL, which some servers read before the body. A path below a GraphQL path, or the same path in another letter case or encoding, is the GraphQL path. A request to any other path is plain HTTP: the controls that run on every request apply to it, and the GraphQL ones do not. An operation sent there is refused with404andNOT_A_GRAPHQL_PATHrather than forwarded, apart from the three forms named there, so still serve GraphQL only on the configured paths.
CORS on refusals from the edge
Section titled “CORS on refusals from the edge”response.cors.allow_origins lists the origins allowed to read GraphPilot’s own rejections. For
those origins, an edge rejection carries CORS headers, so your app can read its error code instead
of seeing a network error. See edge rejections.
Enforce the same limits at your origin
Section titled “Enforce the same limits at your origin”The edge limits are a first filter, not the only one. Keep a second copy of them where every operation ends up, so that a path the edge does not cover, a setting switched off by mistake, or a request that never went through GraphPilot still meets a limit:
- Lock your origin to the edge. Verify
GP-Signatureon every request, the WebSocket upgrade included, and refuse what does not carry a valid one. See request signing and, as a second layer, the origin allowlist. - Limit the WebSocket at your origin. Set a maximum message size on your WebSocket server,
verify the credential in
connection_initbefore you acknowledge the connection, and close the connection when that credential expires. See subscriptions and WebSockets. - Limit operations in your GraphQL server. Apply a depth limit, an alias or field count limit and, where your server offers one, a cost limit to every operation it executes, over HTTP and over the WebSocket alike. The edge defaults (depth 10, 4,000 selections, a 64 KiB document) are a reasonable starting point. Keep introspection off in production there too.
- Limit the request body. A body limit in your HTTP server, no larger than
request.max_body_bytes, keeps that bound in place for any request that reaches your origin some other way.
Related
Section titled “Related”- Go-live checklist for the order to do these in.
- Limits for the platform ceilings behind these limits.
- Codes for what each refusal reports.
- Telling your clients apart for declaring clients and giving each its own limits.
- Subscriptions and WebSockets for what your origin checks inside a WebSocket.