Skip to content

Telling your clients apart

Every request comes from something: a web app, a mobile build from eighteen months ago, a partner’s integration, a script somebody wrote once. By default GraphPilot serves them all alike and reports them as one stream. A client profile gives one caller a name. With a name, you can find it in Insights, hold it to a minimum version, give it its own limits, or refuse it.

This page owns the rules for client profiles; the configuration reference lists the keys.

The running example is a service with three callers: your own web app, which can send a header you agree on; the gpilot CLI, which cannot; and the partner Acme, whose token your identity provider issues with client_id: acme.

  • A deployed service and its gpilot.toml.
  • For a profile matched on a verified claim, an [auth.providers.<name>] block; see authentication.
gpilot.toml
[[client.profile]]
name = "web-app"
when = "header:gp-client-name == 'web-app'"
version = "header:gp-client-version"

when decides whether a request is that client, and is read as a truth. version says where its version comes from, and is read as a value. Both are policy expressions, so a header, a cookie, a query parameter, a verified claim or a piece of the User-Agent all work. gp-client-name and gp-client-version are read by expressions but removed before your origin.

A client is declared, not discovered. The name recorded is the one you wrote, never one the caller sent, so web-app does not also arrive as webapp and WebApp. Profiles are tried in file order and the first whose when is true wins, so put the narrower profile first.

2. Recognize clients that cannot cooperate

Section titled “2. Recognize clients that cannot cooperate”

A CLI you did not write, or a mobile build that shipped years ago, cannot be asked to send a new header. Recognize it by what it already sends:

gpilot.toml
[[client.profile]]
name = "gp-cli"
when = "starts_with(header:user-agent, 'gp-cli/')"
version = "split(header:user-agent, '/', 1)"

See text functions.

gpilot.toml
[auth.providers.idp]
type = "jwt"
allowed_algorithms = ["RS256"]
jwks_url = "https://idp.example.com/.well-known/jwks.json"
allowed_issuers = ["https://idp.example.com/"]
allowed_audiences = ["my-api"]
[[client.profile]]
name = "partner-acme"
when = "auth:idp.client_id == 'acme'" # who: from the token
version = "auth:idp.client_version || header:gp-client-version" # which build

auth:idp.client_id reads a claim the idp provider verified before any expression ran, so the caller cannot choose it. Client profiles are resolved after authentication for exactly this. The claim’s name is your identity provider’s: client_id or azp in a client-credentials flow, or any claim it mints. The version is read from the token when present and from the header otherwise, because || yields the first side that resolves.

What a claim changes:

SettingMatched on a headerMatched on a claim
unknown = "reject"Catches stale deployments and misrouted trafficRefuses anyone who did not authenticate as a declared client
min_versionRetires an old buildThe same, and the build cannot dodge the floor by claiming to be another client
cache = "off", max_ageA switch a stranger can flip, adding origin loadA switch only that client can flip

Nobody can inflate another client’s numbers by copying its header, either. The version stays self-reported while it comes from a header, so a client can still over-report it to clear a floor.

Deploy with gpilot deploy, leave client.unknown at "allow", and look at what matched. In Insights, each request’s Client tab shows the Recognized client and its version, or that no profile matched, on every plan. From Jet (Starter), the request list can show client name and version as columns. Insights has no aggregated per-client view. The requests that match no profile are the callers you have not described; describe them as in steps 1 to 3.

gpilot.toml
[[client.profile]]
name = "web-app"
when = "header:gp-client-name == 'web-app'"
version = "header:gp-client-version"
min_version = "2.1.0"

Anything below the floor is refused with 403 and CLIENT_VERSION_UNSUPPORTED, with the required version in the message. Versions compare properly: 4.10.0 is newer than 4.9.0, and 4.2.0+build.7 is the same release as 4.2.0. A client that reports no version, or one that does not parse, is refused. A min_version GraphPilot cannot parse refuses nothing and logs CLIENT_MIN_VERSION_UNREADABLE, so a typo costs you the floor, not your traffic.

A profile can tighten what the service allows:

KeyGives that client
max_bodyA smaller request body limit
max_query_depthA shallower nesting ceiling
max_document_bytesA shorter document limit
max_selectionsA lower selection limit
persisted_query = "required"Hashes only, with no document in the payload

Each sits under the service-wide setting of the same name, and the stricter one decides; a looser per-client value raises nothing. For plain HTTP requests, only max_body applies; the other four need a GraphQL operation. max_query_depth, max_document_bytes and max_selections are not checked on a cache hit, which parses nothing and costs your origin nothing.

persisted_query = "required" refuses the APQ registration handshake, because that payload carries a document, so such a client’s operations have to be pushed with gpilot pq push. It does not apply to subscriptions: operations on a WebSocket travel inside the socket, where the edge never reads them.

None of these limits, and none of the refusals in steps 5 and 7, applies to a browser’s CORS preflight: an OPTIONS request with an Access-Control-Request-Method header and no body. A preflight carries none of the headers a profile is matched on and runs no operation, so refusing it would block every cross-origin request of the very client the profile describes. The request that follows the preflight is checked as usual, and an OPTIONS request with a body is checked like any other request.

Two keys relax instead: cache = "off" takes the client off the cache, and max_age makes it refuse entries older than a number of seconds. Both hand work to your origin, so match such a profile on a verified claim.

Last, once Insights shows nothing legitimate left unnamed:

gpilot.toml
[client]
unknown = "reject"

A request that matches no profile is refused with 403 and UNKNOWN_CLIENT, before the cache and before your origin. A CORS preflight is the exception, see step 6.

Terminal window
curl -sS -o/dev/null -w '%{http_code}\n' "https://$GRAPHPILOT_HOST/graphql" \
-H 'content-type: application/json' \
-H 'gp-client-name: web-app' -H 'gp-client-version: 2.0.0' \
-d '{"query":"{ __typename }"}'

403, and the body’s extensions.code is CLIENT_VERSION_UNSUPPORTED. With gp-client-version: 2.1.0 it passes, and Insights shows Recognized client web-app with version 2.1.0. With no gp-client-name at all it is 403 with UNKNOWN_CLIENT once step 7 is deployed. UNKNOWN_CLIENT, CLIENT_VERSION_UNSUPPORTED and PERSISTED_QUERY_REQUIRED reach the caller; a body over max_body reaches it as PAYLOAD_TOO_LARGE, and only the Edge codes in the request’s Overview tab say CLIENT_BODY_TOO_LARGE and name the client. See error and log codes.

  • A claim-matched profile plus unknown = "reject" means every request must authenticate. A request without a token carries no claims, so it matches no claim-based profile. Give anonymous callers a profile of their own, or leave unknown at "allow".
  • unknown = "reject" also applies to WebSocket handshakes. A browser cannot set gp-client-name on a handshake, so a profile matched only on that header refuses your own subscriptions with UNKNOWN_CLIENT. Match the handshake on a cookie, a query parameter or the User-Agent, or give it a profile of its own; see subscriptions and WebSockets.
  • A missing claim is not a mismatch, it is nothing. A token without client_id leaves the comparison undecided, and when reads that as not this client; see a request the expression cannot decide.
  • Never write a version floor with >=. Ordering operators compare numbers, 4.2.0 is not one, and the profile silently matches nothing. Use min_version, or semver_gte inside an expression.
  • Every client shares one cache. A profile decides whether a request is served and how old an entry it accepts, never what the response contains. A client over its max_age goes to your origin and reports PASS with CACHE_SKIPPED_ENTRY_NOT_REPLACED; the entry stays as it is for everyone else. Different content per client needs a vary rule, at its hit-rate cost.
  • Some combinations refuse the deployment: min_version without version; max_query_depth = 0, max_document_bytes = 0 or max_selections = 0, which would refuse everything (leave the key out for no ceiling); and max_age with cache = "off".
  • CLIENT_MIN_VERSION_UNREADABLE and CLIENT_PERSISTED_QUERY_UNENFORCEABLE in your logs mean a setting is being ignored. Fix the configuration.