Authentication at the edge
GraphPilot verifies the JWTs your clients send, and that buys you two things.
A private response can be cached without being shared. Without a verified claim there is
nothing to separate one caller’s copy from another’s, and rather than risk serving the wrong
one, GraphPilot does not cache the response at all. How narrowly it is separated is your
private cache policy to write: a claim like sub gives one
entry per person, while a claim naming an organization or one of your customers gives one entry that everyone
in it shares. Verifying the token is what makes either possible; it is not what decides which
you get.
A broken credential is stopped at the edge. A token with a bad signature, an expired one, or
one failing a constraint you configured is answered with 401 from the nearest location and
never reaches your origin. Your origin does not spend a request rejecting it, and does not have
to be reachable for it to be rejected. This is the default and it is
configurable per reason.
Be precise about what the second one is worth, because it is narrower than “the edge protects your API”: only credentials that fail to verify are stopped. A valid token belonging to somebody who may not see the data still reaches your origin, because whether they may is your origin’s decision and not something a signature answers. See What this does not do.
Both are visible to your end users, so it is worth knowing exactly what happens.
Configuring a provider
Section titled “Configuring a provider”[auth.providers.idp]type = "jwt"allowed_algorithms = ["RS256"]jwks_url = "https://idp.example.com/.well-known/jwks.json"allowed_issuers = ["https://idp.example.com/"]
[cache.policy]private = 'auth:idp.sub'The block name is what the policy refers to. auth:idp.sub reads the sub claim of a
token verified by the idp provider; nested claims work too (auth:idp.org.id). Every .
steps into a nested claim. A step may contain letters, digits, _ and -; any other claim name
is written in quotes and is then one step, dots and slashes included, so a claim whose name is a
URL is read as auth:idp.'https://acme.example/org_id'. Quoted steps escape like string literals
(\' for a quote). In gpilot.toml, such a path goes in a double-quoted string, which holds the
single quotes as they are: private = "auth:idp.'https://acme.example/org_id'".
allowed_algorithms is required, and it is a security control. The algorithm a token
names in its own header only selects the verifier after this list has accepted it. Accepting
whatever the token claims would allow a downgrade: an attacker presents an HS256 token to an
RS256 provider and signs it with the public key as the shared secret.
allowed_issuers is required for a provider that verifies with a public key: one that sets
jwks_url or public_key, or lists an asymmetric algorithm (RS*, PS*, ES*, ES256K,
EdDSA). Without it, or with an empty list, the configuration is refused when it is read and the
deployment fails:
a JWT provider that verifies with `jwks_url`, `public_key` or an asymmetric algorithm must set`allowed_issuers`: public keys can be shared across an issuer platform's tenants, and without anissuer check a token minted for another tenant verifies hereA provider that only uses a shared secret (HS*, BLAKE2B) may leave it unset.
The key to verify with comes from jwks_url, a PEM public_key, or a shared secret, and
default_key_id names the key to use for a token that carries no kid. Each is in the
configuration reference.
Optional constraints, all unset by default: allowed_issuers (for a shared-secret provider),
allowed_audiences, required_nonce, required_key_id, time_tolerance, max_validity, max_token_length,
max_header_length, accept_future, allow_missing_exp.
Set allowed_issuers and allowed_audiences. A signature only proves which key signed a
token. Some identity platforms sign every tenant’s tokens with the same keys and tell tenants
apart only by iss and aud (Firebase Auth is the common case). A provider that checks neither
accepts a token minted in someone else’s project as a verified identity, with its own cache
partition and whatever auth: conditions its claims satisfy. A public-key provider without
allowed_issuers does not deploy at all, as above. For the rest, every deployment warns about a
JWT provider that sets no allowed_audiences, and about a shared-secret provider that sets no
allowed_issuers (source auth_provider, see
diagnostics); the deployment still goes ahead.
[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"]A token must carry exp. A token that verifies but states no expiry is rejected with
AUTH_INVALID_TOKEN, because nothing else would ever stop accepting it. If your issuer
genuinely mints tokens without exp, set allow_missing_exp = true on the provider, and set
max_validity with it, so that a token’s iat bounds how long it is accepted. A token that does
state an expiry is held to it either way.
Names come from the JWT registry, which is what your identity provider documents: ES256K
with a capital K, and EdDSA rather than Ed25519. These fourteen are accepted, spelled
exactly as shown:
| Family | Names |
|---|---|
| HMAC (shared secret) | HS256, HS384, HS512 |
| BLAKE2b (shared secret, not in the JWT registry) | BLAKE2B |
| RSA | RS256, RS384, RS512 |
| RSA-PSS | PS256, PS384, PS512 |
| Elliptic curve | ES256, ES384, ES256K |
| Edwards curve (Ed25519) | EdDSA |
An unknown name is refused when the configuration is read, not when a request arrives.
By default the token is read from Authorization, with a Bearer prefix stripped if present.
The scheme is matched case-insensitively, so bearer and BEARER work too. Set header to
read from a different one, where a bare token without any scheme is also accepted.
Example: Auth0 with one entry per organization
Section titled “Example: Auth0 with one entry per organization”A B2B shop signs its customers in with Auth0 Organizations,
and every member of one customer sees the same negotiated price list. Auth0 adds an org_id claim
to the access token of a user who signed in through an organization, and that claim is what the
price list is keyed on:
[auth.providers.auth0]type = "jwt"allowed_algorithms = ["RS256"]jwks_url = "https://shop-example.eu.auth0.com/.well-known/jwks.json"allowed_issuers = ["https://shop-example.eu.auth0.com/"]allowed_audiences = ["https://shop-api.example.com"]
[cache.policy]private = 'auth:auth0.sub'
[[cache.policy.vary]]name = "x-gp-organization"value = 'auth:auth0.org_id'scope_only = trueenum CacheControlScope { PUBLIC PRIVATE ORGANIZATION @vary(names: ["x-gp-organization"])}
type PriceList @cacheControl(maxAge: 300, scope: ORGANIZATION) { currency: String! entries: [Price!]!}- Copy the issuer from a real token. Auth0’s
issis your tenant’s domain with a trailing slash, andallowed_issuershas to match it exactly.allowed_audiencesis the identifier of the API you registered in Auth0. Auth0 can issueaudas an array; a token is accepted when any of its audiences is on the list, and a token withoutaudis refused. - A user without an organization is not stored. A token without
org_idderives nox-gp-organization, so a price list answered to it is not cached rather than shared (CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR). - Key on everything the price list depends on. If members of one customer see different
prices by role, the role has to be in the key too, as a string claim: an
array such as Auth0’s
permissionsresolves to nothing, and a namespaced claim is read by quoting its name (auth:idp.'https://shop-api.example.com/role'). See is your field really organization-uniform?
Products and categories, scoped PUBLIC, stay one entry for every customer, because the rule is
marked scope_only. The recipe caching per Auth0 organization
adds the role to the key and walks through the requests to send and what Insights shows.
Opaque API keys
Section titled “Opaque API keys”Not every credential is a JWT. A provider of type api_key recognizes an opaque key by its
prefix:
[auth.providers.orgkey]type = "api_key"prefixes = ["sk_"]This provider verifies nothing, and cannot. An API key is a secret its issuer holds, with no signature and no key material to check against. Your origin remains the authority on whether a key is valid, exactly as before.
What it does buy you is two things:
- A JWT and an API key can share one header. A value matching none of the prefixes is
treated as not this provider’s credential rather than as a broken one, so the JWT provider
reading the same header still gets its turn. Without this, every API key on
Authorizationwould be refused as a malformed token. - API key traffic stays cacheable. The provider contributes one claim,
key, holding a SHA-256 digest of the key.private = "auth:orgkey.key"therefore resolves, and responses are partitioned per key instead of falling out of the cache entirely. The digest is what travels, never the key itself.
prefixes is required and may not be empty or contain an empty string. Such a list would claim
every credential on the header, every broken token would read as somebody else’s credential, and
the service would silently stop refusing anything at the edge. The configuration is rejected
rather than accepted with that effect.
header defaults to Authorization, with a Bearer prefix stripped if present.
Migrating between issuers
Section titled “Migrating between issuers”Configure both, reading the same header:
[auth.providers.old]type = "jwt"allowed_algorithms = ["RS256"]jwks_url = "https://old-idp.example.com/.well-known/jwks.json"allowed_issuers = ["https://old-idp.example.com/"]
[auth.providers.new]type = "jwt"allowed_algorithms = ["RS256"]jwks_url = "https://new-idp.example.com/.well-known/jwks.json"allowed_issuers = ["https://new-idp.example.com/"]A token minted by either issuer is accepted, and the provider it does not belong to stays quiet. A credential is only refused when no provider reading that header could verify it, so you do not need your clients to cut over at the same moment as your configuration.
Point your cache policy at whichever provider carries the claim you partition by. During the
overlap you will want both, which a policy expresses with ||:
[cache.policy]private = 'auth:new.sub || auth:old.sub'What happens to a request
Section titled “What happens to a request”| The request | GraphPilot’s response |
|---|---|
| No credential | Forwarded unchanged. Nothing is rejected. A private response is not cached, since there is nothing to partition it by. |
| A token that verifies | Forwarded, and its claims become available to your cache policy. Private responses are cached, partitioned by whatever that policy resolves to. |
| A token that does not verify (bad signature, expired, failing a constraint) | Rejected with 401. It never reaches your origin. |
| A token naming a key GraphPilot does not have | Rejected with 401, with a message that points at the key state rather than at the token. |
A request without a credential is never rejected. Adding an auth provider to a service does not close it off: public queries keep working exactly as before.
The last two rows share a status code and differ in message on purpose. A client told its token is invalid will try to authenticate again; when the real cause is that GraphPilot does not yet have the key to check it with, that retry cannot succeed, so the response says so instead.
Choosing what a rejection does
Section titled “Choosing what a rejection does”Rejecting is the default, and you can turn it off per reason:
[auth.rejection]invalid_token = "reject" # the default for all threeunknown_key_id = "continue"key_material_unavailable = "continue"continue forwards the request as if it carried no credential, leaving the decision to your
origin. It does not weaken the cache: without a verified claim there is nothing to partition
by, so a private response is simply not cached, exactly as for an anonymous request.
The setting is per reason because the reasons are yours to weigh differently. A broken token is your client’s problem and stopping it at the edge saves your origin the work. Stale key material is GraphPilot’s, and you may prefer your users reach your origin while it catches up rather than see a 401 they can do nothing about.
Key rotation
Section titled “Key rotation”Your issuer’s key set (JWKS) is fetched from jwks_url when you deploy and delivered to the
edge with the service, so on the normal path your identity provider is not in the request path:
if it is slow or down, requests carrying a known key are still verified.
A deployment whose key set is unusable stops before it goes live, because the new version
would refuse every token for that provider with 401. The previous deployment keeps serving. The
message starts with one of these codes and names every address that was tried:
| Code | When |
|---|---|
JWKS_ | The jwks_url is not a URL, is not https://, has no host, or carries credentials. Plain http:// is accepted only on localhost, 127.0.0.1 and [::1] (a key set over plain http could be swapped by anyone on the path), and refused with a key set must be served over https for every other host. Not retried. |
JWKS_ | No address answered with a 2xx. The deployment retries it, since the issuer may only be down for a moment. |
JWKS_ | An answer came back, but it is not JSON, has no keys array, is empty, or holds no key that can verify one of the provider’s allowed_ (a key marked for encryption does not count). Not retried. |
A provider that allows only HMAC algorithms never verifies with this set, so for it any non-empty
set is enough. The jwks_url has to be reachable from the public internet: a connection to an
address in a private network is not made, which ends in JWKS_FETCH_FAILED. A jwks_url on one of the service’s own hostnames is
fetched from your origin first, with the same path and query, because during a deployment that
hostname still serves the previous version, or nothing yet; the jwks_url itself is the
fallback. That request to your origin is not signed, so an origin
that refuses unsigned requests has to serve the key set without a signature, or jwks_url has to
point at your identity provider directly.
Each fetch is bounded: 10 seconds in total, 5 seconds without data, at most 3 redirects and a body of at most 512 KiB. An address past any of these counts as not answering. The codes are also listed under deployment failures.
A token naming a key id (kid) that the delivered set does not hold is what a rotation between
deployments looks like. When that happens, GraphPilot fetches jwks_url again, so a rotation is
usually picked up by the first request that carries the new key, without a redeploy.
An edge location fetches a provider’s jwks_url at most once a minute, however many requests name
an unknown key, and only over https (plain http only on localhost, 127.0.0.1 and [::1]).
For any other plain http URL the refresh is skipped and logged, and the keys delivered at deploy
time keep verifying. Until a fetch finds the key, a token naming it is rejected with
AUTH_UNKNOWN_KEY_ID (or forwarded, if you set unknown_key_id = "continue"
above). So after a rotation, a token with the new key can be
rejected for up to a minute if the edge location fetched shortly before the issuer published it.
So plan a rotation the way a JWKS endpoint is designed for: publish the new key before you sign with it. Both keys are served, GraphPilot accepts both, and no request depends on a fetch.
WebSocket subscriptions
Section titled “WebSocket subscriptions”A provider verifies credentials that arrive as request headers, which on a WebSocket means the
handshake and nothing else. A token sent in the connection_init payload, as browser clients of
graphql-transport-ws do, is never seen at the edge, so your origin has to verify it. See
subscriptions and WebSockets.
What this does not do
Section titled “What this does not do”GraphPilot is not your authorization layer. It checks that a token is authentic and then uses a claim as a cache dimension. Whether that user may see the data is your origin’s decision, and every request that carries a valid token still reaches it (unless it is served from that user’s own cache entry).