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.
What you need
Section titled “What you need”- A deployed service and its
gpilot.toml. - For a profile matched on a verified claim, an
[auth.providers.<name>]block; see authentication.
1. Name your own app
Section titled “1. Name your own app”[[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:
[[client.profile]]name = "gp-cli"when = "starts_with(header:user-agent, 'gp-cli/')"version = "split(header:user-agent, '/', 1)"See text functions.
3. Match a partner on a verified claim
Section titled “3. Match a partner on a verified claim”[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 tokenversion = "auth:idp.client_version || header:gp-client-version" # which buildauth: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:
| Setting | Matched on a header | Matched on a claim |
|---|---|---|
unknown = "reject" | Catches stale deployments and misrouted traffic | Refuses anyone who did not authenticate as a declared client |
min_version | Retires an old build | The same, and the build cannot dodge the floor by claiming to be another client |
cache = "off", max_age | A switch a stranger can flip, adding origin load | A 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.
4. Deploy and look
Section titled “4. Deploy and look”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.
5. Retire old versions
Section titled “5. Retire old versions”[[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.
6. Give a client its own limits
Section titled “6. Give a client its own limits”A profile can tighten what the service allows:
| Key | Gives that client |
|---|---|
max_body | A smaller request body limit |
max_ | A shallower nesting ceiling |
max_ | A shorter document limit |
max_ | A 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.
7. Refuse what you did not declare
Section titled “7. Refuse what you did not declare”Last, once Insights shows nothing legitimate left unnamed:
[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.
Check that it works
Section titled “Check that it works”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.
Pitfalls
Section titled “Pitfalls”- 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 leaveunknownat"allow". unknown = "reject"also applies to WebSocket handshakes. A browser cannot setgp-client-nameon a handshake, so a profile matched only on that header refuses your own subscriptions withUNKNOWN_CLIENT. Match the handshake on a cookie, a query parameter or theUser-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_idleaves the comparison undecided, andwhenreads that as not this client; see a request the expression cannot decide. - Never write a version floor with
>=. Ordering operators compare numbers,4.2.0is not one, and the profile silently matches nothing. Usemin_version, orsemver_gteinside 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_agegoes to your origin and reportsPASSwithCACHE_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_versionwithoutversion;max_query_depth = 0,max_document_bytes = 0ormax_selections = 0, which would refuse everything (leave the key out for no ceiling); andmax_agewithcache = "off". CLIENT_MIN_VERSION_UNREADABLEandCLIENT_PERSISTED_QUERY_UNENFORCEABLEin your logs mean a setting is being ignored. Fix the configuration.