For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.
Signed JWT (jwtSign)
Sign a short-lived JWT with your own private key on every request to a backend.
Sign a short-lived JWT with your own private key on every request to a backend.
About
Some upstreams do not accept a durable credential at all. The Snowflake SQL API, for example, requires a JWT that is signed with the caller’s private key on each call. The key and secretRef backend authentication methods cannot serve those upstreams, because they forward a static credential.
With the jwtSign backend authentication method, the gateway mints the token itself. It reads a PEM-encoded private key from a Kubernetes Secret, signs a JWT that carries the claims that you configure, and writes that token to each request that it forwards to the backend. Nothing is cached, so every request is signed afresh.
Two behaviors are worth knowing before you configure the method:
- The signer (gateway) owns the time claims. The gateway always sets
iatandexp, and rejects a policy that tries to configureiat,exp, ornbf. It backdatesiatby 10 seconds, so that a validator whose clock trails the gateway still accepts a freshly minted token. A decoded token therefore spans thettlplus 10 seconds, and never carries annbfclaim. - The token overwrites only what sits at its location. By default, the gateway writes the
Authorizationheader, replacing any credential that the client sent there. If you pointlocationat a different header, query parameter, or cookie, the client’sAuthorizationheader is forwarded to the backend untouched. Remove it with a request filter if the upstream must not see it.
Note
The jwtSign method is not the same as the clientAuth.privateKeyJwt setting on Cross App Access. The two share the signing implementation, but privateKeyJwt authenticates the gateway to an OAuth token endpoint, and jwtSign sends a signed JWT to the backend itself.
Before you begin
Follow the Get started guide to install agentgateway.
Follow the Sample app guide to create a gateway proxy with an HTTP listener and deploy the httpbin sample app.
Get the external address of the gateway and save it in an environment variable.
Tip
Kind cluster? Kind does not support
LoadBalancerservices by default. To use this option with a Kind cluster, install and runcloud-provider-kind.export INGRESS_GW_ADDRESS=$(kubectl get svc -n agentgateway-system agentgateway-proxy -o jsonpath="{.status.loadBalancer.ingress[0]['hostname','ip']}") echo $INGRESS_GW_ADDRESS
Create the signing key Secret
The gateway reads the private key from the signingKey entry of a Secret in the policy’s namespace. The key must be a PEM-encoded RSA or EC private key that matches the algorithm that you configure.
Generate a private key that you use for the signing process. An EC P-256 key is the smallest option, and pairs with the
ES256algorithm.openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out signing-key.pemTo use the default
RS256algorithm instead, generate an RSA key.openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signing-key.pemStore the key in a Secret in the same namespace as the policy, under the
signingKeydata key.kubectl create secret generic jwt-signing-key \ --namespace httpbin \ --from-file=signingKey=signing-key.pem
Configure jwtSign backend authentication
Create an AgentgatewayPolicy that targets the httpbin route and signs every request that the gateway forwards to the backend. The claim values in this example follow the shape that the Snowflake SQL API expects. Replace them with the claims that your upstream requires.
kubectl apply -f- <<EOF apiVersion: agentgateway.dev/v1alpha1 kind: AgentgatewayPolicy metadata: name: jwt-sign-backend-auth namespace: httpbin spec: targetRefs: - group: gateway.networking.k8s.io kind: HTTPRoute name: httpbin backend: auth: jwtSign: signingKeyRef: name: jwt-signing-key alg: ES256 kid: my-signing-key claims: iss: MYACCOUNT.MYUSER.SHA256:my-public-key-fingerprint sub: MYACCOUNT.MYUSER aud: https://myaccount.snowflakecomputing.com ttl: 60s EOFReview the following table to understand this configuration. For more information, see the API docs.
Field Description signingKeyRefRequired Secret in the policy’s namespace that holds the PEM-encoded RSA or EC private key under the signingKeydata key.algJWS signing algorithm: RS256(default),RS384,RS512,PS256,ES256, orES384. The algorithm must match the key family. TheRSandPSalgorithms need an RSA key, and theESalgorithms need an EC key.kidOptional kidheader that the gateway stamps on every token. Omit the field and the gateway writes nokidheader.claimsOptional static claims that the gateway copies into every token, such as iss,sub, andaud. A value can be any JSON value, including a number or an array. Theiat,exp, andnbfclaims are reserved for the signer, and the controller rejects them.ttlOptional token lifetime that the gateway uses for exp. Defaults to300s.locationOptional location that the gateway writes the signed token to. Defaults to the Authorizationheader with aBearerprefix. Set exactly one ofheader,queryParameter, orcookieto change it. At a custom location, the gateway writes the bare token with noBearerprefix.
Verify that requests are signed
Send a request through the gateway to the httpbin
/headersendpoint, which reflects the headers that the backend received. Because the gateway writes the token to theAuthorizationheader, the command decodes that header to show the protected header and the payload of the token that the backend received.curl -s "http://$INGRESS_GW_ADDRESS:80/headers" -H "host: www.example.com" | python3 -c ' import sys,json,base64 h=json.load(sys.stdin)["headers"] tok=(h.get("Authorization") or h.get("authorization")) tok=(tok[0] if isinstance(tok,list) else tok).split()[1] seg=lambda i: json.loads(base64.urlsafe_b64decode(tok.split(".")[i]+"==")) print(json.dumps(seg(0))) print(json.dumps(seg(1))) print("exp - iat:", seg(1)["exp"] - seg(1)["iat"])'In the example output, the protected header carries the configured algorithm and key ID, and the payload carries your claims plus the timestamps of the signer. The difference between
expandiatis 70 seconds, which is the 60-secondttlplus the 10-second backdate, and the payload carries nonbfclaim.{"typ": "JWT", "alg": "ES256", "kid": "my-signing-key"} {"aud": "https://myaccount.snowflakecomputing.com", "iss": "MYACCOUNT.MYUSER.SHA256:my-public-key-fingerprint", "sub": "MYACCOUNT.MYUSER", "iat": 1786485823, "exp": 1786485893} exp - iat: 70Confirm that the gateway caches nothing. Send two requests a second or more apart, and compare the tokens. The gateway signs each request afresh, so the tokens and their
iatvalues differ.curl -s "http://$INGRESS_GW_ADDRESS:80/headers" -H "host: www.example.com" | python3 -c 'import sys,json; print(json.load(sys.stdin)["headers"]["Authorization"][0])' sleep 2 curl -s "http://$INGRESS_GW_ADDRESS:80/headers" -H "host: www.example.com" | python3 -c 'import sys,json; print(json.load(sys.stdin)["headers"]["Authorization"][0])'
Troubleshoot
A jwtSign policy that the gateway cannot use fails closed. The gateway rejects every request on the route instead of forwarding an unsigned one. Requests return a 500 with a general message, which deliberately does not name the credential that the gateway could not resolve.
backend authentication failed: jwtSign configuration is invalidCheck the policy status first. The controller reports the problems that it can see on the Accepted condition, which stays True with the reason PartiallyValid.
kubectl get AgentgatewayPolicy jwt-sign-backend-auth -n httpbin \
-o jsonpath='{.status.ancestors[0].conditions[?(@.type=="Accepted")].message}'| Status message | Cause |
|---|---|
failed to resolve jwtSign signing secret <namespace>/<name> | The Secret that signingKeyRef names does not exist in the policy’s namespace. |
secret <namespace>/<name> missing signingKey value | The Secret exists, but it has no signingKey data key. A key that is stored under any other name, such as privateKey, produces this message. |
jwtSign claim "iat" is reserved for the signer and cannot be configured | A reserved claim (iat, exp, or nbf) is set under claims. The API server accepts the policy, because the claims map is opaque to CRD validation, and the controller rejects it afterwards. |
Cleanup
You can remove the resources that you created in this guide.kubectl delete AgentgatewayPolicy jwt-sign-backend-auth -n httpbin
kubectl delete secret jwt-signing-key -n httpbin