Checking tokens at the gateway
For backend and platform engineers. At the end of this page, your API gateway refuses requests that don’t carry a valid attestation token, and passes the others to your API with the device key in a header.
Checking at the gateway replaces checking in the API’s code, described in Verifying tokens in your API. The rules are the same.
Before you start
- A verifier serving your published policy. See Deploying the verifier.
- Its JWKS URL,
<verifier>/.well-known/jwks.json, reachable from the gateway. - The issuer and audience your verifier puts in its tokens:
TRUSTPIN_TOKEN_ISSUERandTRUSTPIN_TOKEN_AUDIENCE, by defaulttprn::attest::<organization id>::<project id>andtprn::project::<organization id>::<project id>.
What every recipe checks
- An ES256 signature with a key from the verifier’s JWKS, chosen by
kid. The JWKS is cached and fetched again when an unknownkidarrives, at most once every 30 seconds, with a 5-second timeout. Envoy refreshes on a schedule instead; see its note below. - The header
typistrustpin-attest+jwt. iss,aud, andexpwith 60 seconds of leeway.tp.prjis your project,tprn::project::<organization id>::<project id>. By default it is taken from the audience when the audience has that form.- No token with
tp.env == "debug". bind, when present, equalsbase64url(SHA-256(<Authorization header value>)). With require-binding on, for policies with Require token binding, a token withoutbindis refused.
A request that passes reaches your API with an X-TrustPin-Sub header, the device key. A request that fails gets 401; Envoy answers 403 for a wrong audience.
The recipes enforce. They refuse a request without the header, and every failed check, whatever the policy’s mode. While the policy is in monitor mode, check tokens in your API instead, or adapt the recipe to log and let the request through. See Rolling out.
The recipes
| Gateway | How | Files | Tested |
|---|---|---|---|
| nginx | auth_request to an njs module (WebCrypto). The official nginx images include njs | nginx.conf, trustpin_attest.js | In Docker, in the verifier’s CI |
| Envoy | jwt_authn for the signature, iss, aud and exp; a Lua filter for typ, tp.prj, debug tokens and binding | envoy.yaml, trustpin_attest.lua | In Docker, in the verifier’s CI |
| Kong (open source, 3.9) | A pre-function plugin using Kong’s bundled OpenSSL bindings; needs untrusted_lua = on | kong.yml, trustpin_attest.lua, render.py | In Docker, in the verifier’s CI |
| Cloudflare | A Worker on the API route (WebCrypto) | worker.mjs, wrangler.toml | The Worker’s check under Node; deployment untested |
| AWS API Gateway (HTTP API) | A Lambda REQUEST authorizer (jose), with a SAM template | index.mjs, template.yaml, package.json, package-lock.json | The handler under Node; deployment untested |
The files use placeholders: https://attest.example.com for the verifier, tprn::project::org_example::prj_example for the audience, and the test host issuer:18300 for the upstreams. Replace them with your own values. README.md comes with them.
The two options
| Gateway | Expected project | Require binding |
|---|---|---|
| nginx | set $trustpin_project (empty: the audience) | set $trustpin_require_bind on |
| Envoy | PROJECT in trustpin_attest.lua (preset to the audience) | REQUIRE_BIND in trustpin_attest.lua |
| Kong | PROJECT in trustpin_attest.lua (empty: the audience) | REQUIRE_BIND in trustpin_attest.lua |
| Cloudflare | TRUSTPIN_PROJECT var (empty: the audience) | TRUSTPIN_REQUIRE_BIND = "true" |
| AWS | Project parameter, TRUSTPIN_PROJECT (empty: the audience) | RequireBind parameter, TRUSTPIN_REQUIRE_BIND=true |
Set the project explicitly when your verifier uses a custom audience; leave it empty with the default audience.
Notes per gateway
- nginx:
resolveris needed, because the JWKS location is proxied by name. The JWKS is kept in a shared zone for all workers and refreshed every 5 minutes; one request every 30 seconds across all workers may fetch it. Use one zone per verifier. - Envoy:
jwt_authnfetches the JWKS in the background, at start and everycache_duration(5 minutes), and never on a request. After the verifier’s signing keys rotate, tokens signed with the new key are refused until the next refresh; lowercache_durationif that window matters. The Lua filter carries its own SHA-256, because Envoy’s Lua has none. - Kong: the check lives in
trustpin_attest.lua;python3 render.pyembeds it intokong.yml. Kong 3.9 is the last open-source Kong image. Kong Enterprise’s OpenID Connect plugin can check the signature and claims natively, but the binding still needs thepre-function. - Cloudflare: set the
[vars]inwrangler.toml, and the route. - AWS: the authorizer receives both headers as identity sources and must not be cached (
ReauthorizeEvery: 0), because the binding depends on theAuthorizationheader.
Built-in JWT authorizers
A gateway’s own JWT check, such as the HTTP API’s built-in JWT authorizer, can check the signature, iss, aud and exp, but not typ, tp.prj, debug tokens or the binding. Use the recipe instead, or add those checks in the API.
These samples may be copied and modified freely, under the verifier licence’s Code Samples clause.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Every request gets 401 | The recipe’s issuer, audience or project differs from the verifier’s | Compare them with TRUSTPIN_TOKEN_ISSUER and TRUSTPIN_TOKEN_AUDIENCE |
Envoy answers 403 | The token’s audience isn’t the one configured | Set the audience to TRUSTPIN_TOKEN_AUDIENCE |
| Envoy refuses tokens just after a key rotation | The JWKS refreshes on its schedule only | Wait for the next refresh, or lower cache_duration |
| Requests are refused in monitor mode | The recipes enforce whatever the mode | Check tokens in the API during monitor |
| Bound tokens are refused | The gateway hashes a different value from the one the app bound | Bind to Authorization, and pass that header to the gateway unchanged |