Skip to content

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.

[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 an
issuer check a token minted for another tenant verifies here

A 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:

FamilyNames
HMAC (shared secret)HS256, HS384, HS512
BLAKE2b (shared secret, not in the JWT registry)BLAKE2B
RSARS256, RS384, RS512
RSA-PSSPS256, PS384, PS512
Elliptic curveES256, 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:

gpilot.toml
[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 = true
schema.graphql
enum 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 iss is your tenant’s domain with a trailing slash, and allowed_issuers has to match it exactly. allowed_audiences is the identifier of the API you registered in Auth0. Auth0 can issue aud as an array; a token is accepted when any of its audiences is on the list, and a token without aud is refused.
  • A user without an organization is not stored. A token without org_id derives no x-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 permissions resolves 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.

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 Authorization would 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.

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'
The requestGraphPilot’s response
No credentialForwarded unchanged. Nothing is rejected. A private response is not cached, since there is nothing to partition it by.
A token that verifiesForwarded, 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 haveRejected 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.

Rejecting is the default, and you can turn it off per reason:

[auth.rejection]
invalid_token = "reject" # the default for all three
unknown_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.

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:

CodeWhen
JWKS_URL_REFUSEDThe 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_FETCH_FAILEDNo address answered with a 2xx. The deployment retries it, since the issuer may only be down for a moment.
JWKS_NO_USABLE_KEYAn 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_algorithms (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.

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.

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).