Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

Per-Request Minted JWTs for Agent Backends with agentgateway

Originally published at webofmike.com on 2026-10-05. The demo repo and every command in it were run before publishing. I built a demo where an AI agent calls a backend API and holds no credential for it. No API key in i

Originally published at webofmike.com on 2026-10-05. The demo repo and every command in it were run before publishing.

I built a demo where an AI agent calls a backend API and holds no credential for it. No API key in its environment, no key file on disk, no token. agentgateway signs a fresh ES256 JWT with its own private key on every request, the backend verifies it against the matching public key, and each token is dead 15 seconds after it was signed. The code is at themsquared/minted-jwt-agents and runs on docker compose with no cloud account.

The feature doing the work is backendAuth.jwtSign, which shipped in agentgateway v1.5.0. Its stated use case is the Snowflake SQL API, which does not accept a static key at all. It works just as well for any backend you control.

Why a gateway-held API key is still a standing credential

Moving a key out of the agent and onto the gateway is most of the win. I wrote that up in Your AI Agent Should Not Hold the LLM API Key: the key lives in one process that does not run agent code or install packages at runtime.

But the key itself has not changed. It does not expire. It is valid from anywhere. And it crosses the wire on every call, so it shows up in backend request logs, debug proxies, and packet captures. Anyone who copies it holds a working credential until someone notices and rotates it.

Per-request signing removes the thing worth copying. The gateway holds a private key that never leaves its process. What crosses the wire is a token that expires in seconds. The backend holds only a public key, which is not a secret.

How agentgateway mints a JWT on every request

This is the whole gateway config. Every field name comes from the v1.5.0 config schema and the Signed JWT (jwtSign) docs:

gateways:
  default:
    port: 3000
routes:
- name: orders-api
  backends:
  - host: backend:8080
    policies:
      backendAuth:
        jwtSign:
          signingKey:
            file: /keys/signing-key.pem
          alg: ES256
          kid: agw-demo-key-1
          claims:
            iss: agentgateway-demo
            sub: orders-agent
            aud: orders-api
            scope: orders:read
          ttl: 15s

Only signingKey is required. alg defaults to RS256 and takes RS256, RS384, RS512, PS256, ES256, or ES384, matched to the key family. ttl defaults to 300 seconds. The token goes in the Authorization header with a Bearer prefix unless you set location.

The docs say nothing is cached. I checked the v1.5.0 source to be sure: the backend auth path calls the signer on every request, and the signer builds claims from the current time each time. There is no token reuse across requests.

Keys go in with plain openssl. The private half is mounted only into the gateway container, and the public half only into the backend:

./keygen.sh
private key -> keys/private/signing-key.pem (gateway only)
public key  -> keys/public/signing-key.pub.pem (backend only)

What the backend receives

The backend is about 90 lines of Python with PyJWT. It requires exp, iat, iss, aud, and sub, checks the ES256 signature against the public key, checks aud and iss, and requires the orders:read scope. It has zero leeway on expiry. On success it echoes back what it verified:

docker compose exec -T agent python /agent/agent.py
  "authenticated_as": "orders-agent",
  "token_header": {
    "typ": "JWT",
    "alg": "ES256",
    "kid": "agw-demo-key-1"
  },
  "token_claims": {
    "aud": "orders-api",
    "iss": "agentgateway-demo",
    "scope": "orders:read",
    "sub": "orders-agent",
    "iat": 1790700449,
    "exp": 1790700474
  },
  "token_expires_in_seconds": 15

Look at iat and exp. They are 25 seconds apart, not 15. agentgateway backdates iat by 10 seconds so a backend whose clock runs slightly behind the gateway does not reject a token as issued in the future. exp is still the signing time plus ttl. So a decoded token looks like it lives ttl + 10s, but it is valid for exactly ttl after signing. If you size lifetimes by reading exp - iat off a decoded token, you will get the wrong number.

One second later, the next request carries a new token:

    "iat": 1790700450,
    "exp": 1790700475

The agent holds nothing

The agent's whole environment:

docker compose exec -T agent env | sort
GATEWAY_URL=http://agentgateway:3000
GPG_KEY=7169605F62C751356D054A26A821E680E5FA6305
HOME=/root
HOSTNAME=6ba6f3215dfa
LANG=C.UTF-8
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PYTHON_SHA256=5c8462af5790baf43a321a1559dbe0db06d1be4300fb85fb53c40060668e548a
PYTHON_VERSION=3.12.14

GPG_KEY is the Python image's release-signing key fingerprint, not a credential. demo.sh also greps the agent's filesystem for PRIVATE KEY and finds nothing. The agent knows one URL.

I also tried to smuggle a token through. An agent request that carries its own Authorization: Bearer not-a-real-token still reaches the backend with the gateway's minted token. agentgateway writes the signed token over whatever the caller put in that header.

Replaying a token lifted from the logs

The backend logs every raw token it receives. That is deliberate: tokens end up in logs in real systems, and a log is the most likely place a bearer token leaks from. The attacker container in the demo plays whoever can read those logs and reach the backend's port.

Calling the backend directly with no token:

401 missing bearer token

Lifting the most recent token out of the backend's logs and replaying it straight away:

TOKEN=$(docker compose logs --no-log-prefix backend | awk '/^TOKEN /{t=$2} END{print t}')
docker compose exec -T attacker python /attacker/replay.py "$TOKEN"
200 authenticated_as: orders-agent expires_in: 14 s

That 200 is the honest part of this demo. A minted token is still a bearer token. Inside its lifetime, whoever holds it can use it. demo.sh then sleeps 16 seconds, past the 15-second ttl, and replays the same token with the same command:

401 token expired

A static key in that same log line would still be working next month.

What a per-request token does not give you

Three limits, all visible in the demo.

There is no jti, so replays inside the window look like fresh requests. agentgateway sets iat and exp at one-second granularity and adds no unique token ID. I fired four requests in the same second and all four carried byte-identical claims. The token strings still differed, because ECDSA signatures are randomized, but a backend has nothing in the claims to deduplicate on. The ttl is your replay control, which is why I set it to 15 seconds and not the 300-second default. Closing that window properly means binding the token to a key the caller holds. That is what DPoP does, and for MCP it is SEP-1932, still an open pull request when I checked on September 29. I covered where it stands in MCP agent identity: what shipped, what to use.

Claims are static per route. The schema describes claims as static values added to every token. The backend sees sub: orders-agent because that is what I wrote in the config, not because it knows which workload called. If two agents share a route, they share a subject. One route or backend per agent identity is how you get distinct subjects today.

The gateway mints for anyone who reaches it. The last step of demo.sh calls the gateway from the attacker container:

200 authenticated_as: orders-agent

I left the inbound leg open on purpose to keep the backend leg readable. For anything real, the caller has to prove its identity to the gateway first. The secretless agents demo does that with agentgateway's JWT authentication, and workload identity federation for AI agents shows where that inbound token should come from on Kubernetes. Per-request signing secures the gateway-to-backend leg. It does not replace agent identity on the way in.

Gotchas

jwtSign claim "exp" is reserved for the signer and cannot be configured. You cannot set iat, exp, or nbf under claims. The gateway rejects the config at load, which --validate-only catches before you start anything:

docker run --rm -v "$PWD/gateway/config.yaml:/config/config.yaml:ro" -v "$PWD/keys/private:/keys:ro" \
  cr.agentgateway.dev/agentgateway:v1.5.0 -f /config/config.yaml --validate-only
Configuration is valid!

Key rotation did not reload through a Docker Desktop bind mount. The v1.5.0 release notes say backend auth secrets reload when their files change. With the native macOS binary, that is what happened. I replaced the key file and the gateway logged resource changed, reloading within seconds. Inside the container on Docker Desktop, replacing the bind-mounted file did nothing, and the gateway kept signing with the old key until I restarted it. I only tested Docker Desktop, so I cannot say whether a Linux host behaves the same way. If you rotate in a container, plan on a restart.

Rotate the public key first. When the native binary picked up a new private key while the backend still trusted the old public key, every request failed:

{
  "error": "invalid token: Signature verification failed"
}

The order matters. Get the new public key onto the backend first, then swap the private key on the gateway. A verifier that accepts two keys during the overlap makes this painless. My demo backend trusts exactly one key, which is how I hit this.

Port 3000 already allocated. My first docker compose up failed with Bind for 0.0.0.0:3000 failed: port is already allocated. The compose file now publishes no host ports at all. Everything talks over the compose network, and the demo runs through docker compose exec.

How this maps to Snowflake

The Snowflake SQL API is the upstream agentgateway's docs and schema use as the example, and it is the reason the feature exists. Snowflake's key-pair authentication wants a JWT signed with the user's private key on each call, with iss and sub built from the account, user, and public-key fingerprint. The upstream example notes that Snowflake caps token lifetime at one hour and also expects an X-Snowflake-Authorization-Token-Type: KEYPAIR_JWT header, which is a plain requestHeaderModifier. I did not test against a real Snowflake account. The claim shapes come from the agentgateway docs, and the demo here uses a mock backend.

Run it

Docker with Compose v2 and openssl. I tested on macOS with Docker Desktop on Apple silicon.

git clone https://github.com/themsquared/minted-jwt-agents.git
cd minted-jwt-agents
./keygen.sh
docker compose up -d --build
./demo.sh

demo.sh runs eight steps in about 20 seconds, most of it the wait for the token to expire. Tear down with docker compose down.

Where this fits

The agent holds no key, the backend holds no secret, and what crosses the wire between them expires faster than anyone can act on a leaked log line. The private key lives in one process, and the only thing the backend has to trust is its public half.

What it leaves open is the other side of the gateway. The next step is putting a verified agent identity on the inbound leg and making the backend's sub reflect it. The rest of the agent identity series works through those pieces. The code for this one is at themsquared/minted-jwt-agents.

Frequently asked questions

How does agentgateway mint a JWT for each backend request?

The backendAuth.jwtSign policy, added in agentgateway v1.5, loads a PEM-encoded RSA or EC private key and signs a new JWT on every request it forwards. The token carries the static claims you configure, such as iss, sub, aud, and a scope, plus iat and exp set by the signer. It goes in the Authorization header as a Bearer token by default. Nothing is cached, and the ttl field sets the lifetime, 300 seconds by default.

Can a replayed per-request JWT still be used after it expires?

No. In the demo the gateway signs tokens with a 15-second ttl. A token lifted from the backend's logs replayed with a 200 while it had 14 seconds left, and the same token replayed after 16 seconds got 401 token expired. Inside its lifetime a leaked token still works, because agentgateway emits no jti claim, so a short ttl is the control that limits replay.

Why does a decoded agentgateway jwtSign token last longer than its ttl?

agentgateway backdates iat by 10 seconds so that a backend whose clock trails the gateway still accepts a freshly minted token. The exp claim is the signing time plus ttl. With ttl set to 15s, the decoded token spans 25 seconds from iat to exp, but it is only valid for 15 seconds after it was signed.

Does the backend see which agent made the request when agentgateway signs the JWT?

Not by default. jwtSign claims are static values in the gateway config, so every token on a route carries the same sub, whichever caller triggered it. To give the backend distinct subjects, configure one route or backend per agent identity. The caller also needs its own authentication to the gateway, because agentgateway mints a token for any request that reaches the listener.

Canonical version, with machine-readable markdown at https://webofmike.com/minted-jwts/index.md: https://webofmike.com/minted-jwts/

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.