Skip to Content
App AttestationChecking tokens at the gateway

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_ISSUER and TRUSTPIN_TOKEN_AUDIENCE, by default tprn::attest::<organization id>::<project id> and tprn::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 unknown kid arrives, at most once every 30 seconds, with a 5-second timeout. Envoy refreshes on a schedule instead; see its note below.
  • The header typ is trustpin-attest+jwt.
  • iss, aud, and exp with 60 seconds of leeway.
  • tp.prj is 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, equals base64url(SHA-256(<Authorization header value>)). With require-binding on, for policies with Require token binding, a token without bind is 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

GatewayHowFilesTested
nginxauth_request to an njs module (WebCrypto). The official nginx images include njsnginx.conf, trustpin_attest.jsIn Docker, in the verifier’s CI
Envoyjwt_authn for the signature, iss, aud and exp; a Lua filter for typ, tp.prj, debug tokens and bindingenvoy.yaml, trustpin_attest.luaIn Docker, in the verifier’s CI
Kong (open source, 3.9)A pre-function plugin using Kong’s bundled OpenSSL bindings; needs untrusted_lua = onkong.yml, trustpin_attest.lua, render.pyIn Docker, in the verifier’s CI
CloudflareA Worker on the API route (WebCrypto)worker.mjs, wrangler.tomlThe Worker’s check under Node; deployment untested
AWS API Gateway (HTTP API)A Lambda REQUEST authorizer (jose), with a SAM templateindex.mjs, template.yaml, package.json, package-lock.jsonThe 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

GatewayExpected projectRequire binding
nginxset $trustpin_project (empty: the audience)set $trustpin_require_bind on
EnvoyPROJECT in trustpin_attest.lua (preset to the audience)REQUIRE_BIND in trustpin_attest.lua
KongPROJECT in trustpin_attest.lua (empty: the audience)REQUIRE_BIND in trustpin_attest.lua
CloudflareTRUSTPIN_PROJECT var (empty: the audience)TRUSTPIN_REQUIRE_BIND = "true"
AWSProject 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: resolver is 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_authn fetches the JWKS in the background, at start and every cache_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; lower cache_duration if 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.py embeds it into kong.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 the pre-function.
  • Cloudflare: set the [vars] in wrangler.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 the Authorization header.

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

SymptomLikely causeFix
Every request gets 401The recipe’s issuer, audience or project differs from the verifier’sCompare them with TRUSTPIN_TOKEN_ISSUER and TRUSTPIN_TOKEN_AUDIENCE
Envoy answers 403The token’s audience isn’t the one configuredSet the audience to TRUSTPIN_TOKEN_AUDIENCE
Envoy refuses tokens just after a key rotationThe JWKS refreshes on its schedule onlyWait for the next refresh, or lower cache_duration
Requests are refused in monitor modeThe recipes enforce whatever the modeCheck tokens in the API during monitor
Bound tokens are refusedThe gateway hashes a different value from the one the app boundBind to Authorization, and pass that header to the gateway unchanged

Next steps