Skip to content
agentgateway has joined the Agentic AI Foundation — Learn more

For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.

JWT bearer grant (RFC 7523)

Verified Code examples on this page have been automatically tested and verified.
Page as Markdown

Exchange the incoming request credential for a per-backend token with the RFC 7523 JWT bearer grant.

Attaches to:

Exchange the incoming token for a backend-scoped token with the RFC 7523 JWT bearer grant.

About

The jwtBearer grant sends the incoming token to the authorization server as the assertion rather than as the subject_token. Use it when the incoming token is a JWT issued by an identity provider that the authorization server trusts, but that did not itself issue the backend token.

For the default RFC 8693 exchange, see Standard token exchange. For an exchange that crosses a trust boundary between two authorization servers, see Cross App Access.

Before you begin

The following examples run against local Keycloak stacks from the agentgateway repository. Make sure that you have the following tools installed:

Exchange a token

Set grantType: jwtBearer to use the RFC 7523 JWT bearer grant, which sends the incoming token as the assertion instead of the subject_token. This grant requires the authorization server to trust the issuer that signed the incoming token. The following example uses a two-realm Keycloak stack, where realm idp issues the assertion and realm backend-oauth trusts it and mints the upstream token.

  1. Start the example stack. It runs Keycloak 26.5 with two realms and an echo upstream on port 18080.

    docker compose -f examples/traffic-token-exchange/jwt-authz-grant/docker-compose.yaml up -d
  2. Review the gateway configuration. The /jwt-bearer-kc route runs a full exchange against real Keycloak; the /jwt-bearer and /obo routes point at a mock token endpoint that logs the exact request the gateway sends. For the full set of fields, see the configuration reference.

    # jwtBearer (RFC 7523) grant demonstration.
    #
    # /jwt-bearer     -> mock token endpoint (:7090), shows the basic request shape
    # /obo            -> mock token endpoint (:7090), Microsoft Entra on-behalf-of request shape
    # /jwt-bearer-kc  -> real Keycloak, full exchange against the two-realm stack (docker-compose.yaml)
    config: {}
    binds:
    - port: 3000
      listeners:
      - name: default
        protocol: HTTP
        routes:
        # --- Microsoft Entra on-behalf-of (OBO) shape, sent to the mock so we can read the form ---
        - name: ms-obo
          matches:
          - path:
              pathPrefix: /obo
          backends:
          - host: localhost:18080
            policies:
              backendAuth:
                oauthTokenExchange:
                  host: localhost:7090           # stands in for login.microsoftonline.com:443
                  path: /token       # stands in for /<TENANT_ID>/oauth2/v2.0/token
                  grantType: jwtBearer
                  clientAuth:
                    clientId: my-app-client-id
                    clientSecret: my-app-client-secret
                    method: clientSecretPost       # puts client_id/client_secret in the BODY
                  scopes:
                  - https://graph.microsoft.com/.default
                  additionalParams:
                    requested_token_use: '"on_behalf_of"'   # CEL string literal
    
        # --- RFC 7523 jwt-bearer against a mock token endpoint (green path) ---
        - name: jwt-bearer-mock
          matches:
          - path:
              pathPrefix: /jwt-bearer
          backends:
          - host: localhost:18080
            policies:
              backendAuth:
                oauthTokenExchange:
                  host: localhost:7090
                  path: /token
                  grantType: jwtBearer
                  audiences:
                  - target-client
    
        # --- RFC 7523 jwt-bearer against real Keycloak (two-realm stack) ---
        - name: jwt-bearer-keycloak
          matches:
          - path:
              pathPrefix: /jwt-bearer-kc
          backends:
          - host: localhost:18080
            policies:
              backendAuth:
                oauthTokenExchange:
                  host: localhost:7080
                  path: /realms/backend-oauth/protocol/openid-connect/token
                  grantType: jwtBearer
                  clientAuth:
                    clientId: requester-client
                    clientSecret: requester-secret
                    method: clientSecretBasic
                  audiences:
                  - target-client
  3. Save the configuration to a file and run agentgateway.

    agentgateway -f config.yaml
  4. Mint an assertion from realm idp.

    ASSERTION="$(curl -s http://localhost:7080/realms/idp/protocol/openid-connect/token \
      -u idp-app:idp-secret -d grant_type=password \
      -d username=idpuser -d password=idppass | jq -r .access_token)"
  5. Send a request to the /jwt-bearer-kc route. The gateway presents the assertion to realm backend-oauth with the JWT bearer grant and forwards the minted token upstream.

    curl -s http://localhost:3000/jwt-bearer-kc -H "authorization: Bearer $ASSERTION"

    In the response, note that the Authorization header forwarded to the upstream contains a different token than the assertion you sent.

  6. Copy the exchanged token from the Authorization header in the previous response, and save it to an environment variable.

    export FORWARDED_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUI...
  7. Decode the token’s payload to confirm the exchange. This command splits off the JWT payload segment and decodes it with jq.

    echo "$FORWARDED_TOKEN" | cut -d. -f2 | jq -R 'gsub("-";"+") | gsub("_";"/") | . + ("=" * ((4 - (length % 4)) % 4)) | @base64d | fromjson'

    The assertion was issued by realm idp, but the exchanged token is issued by realm backend-oauth for aud=target-client.

    {
      "exp": 1783970287,
      "iat": 1783969987,
      "jti": "trrtag:51429e75-075b-df43-fa76-b3d913d71847",
      "iss": "http://localhost:7080/realms/backend-oauth",
      "aud": "target-client",
      "sub": "e2afe4ff-bf5d-45fb-bf44-9ec346fd0818",
      "typ": "Bearer",
      "azp": "requester-client",
      "scope": ""
    }

Microsoft Entra on-behalf-of

The JWT bearer grant is also the shape used by the Microsoft Entra on-behalf-of flow. Use clientSecretPost to send the client credentials in the request body, and additionalParams for the vendor-specific requested_token_use parameter. Values in additionalParams are CEL expressions, so a literal string requires inner quotes.

backendAuth:
  oauthTokenExchange:
    host: login.microsoftonline.com:443
    path: /<TENANT_ID>/oauth2/v2.0/token
    grantType: jwtBearer
    clientAuth:
      clientId: $CLIENT_ID
      clientSecret: $CLIENT_SECRET
      method: clientSecretPost
    scopes:
    - https://graph.microsoft.com/.default
    additionalParams:
      requested_token_use: '"on_behalf_of"'

The jwt-authz-grant example includes an /obo route and a mock token endpoint so that you can inspect the exact on-behalf-of request the gateway sends. For details, see the example README.

Authenticate the gateway to the token endpoint

The clientAuth field decides how agentgateway identifies itself to the token endpoint. Set method to one of three values. Omit clientAuth entirely and agentgateway sends no client authentication fields, which suits a public client or a token endpoint that authenticates by other means.

Send the client ID and secret in the HTTP Basic Authorization header (RFC 6749 §2.3.1). This is the default method, and the one most authorization servers expect.

clientAuth:
  method: clientSecretBasic
  clientId: $CLIENT_ID
  clientSecret: $CLIENT_SECRET

Sign the client assertion with a private key

Use privateKeyJwt when the authorization server registers your client with a public key rather than a secret. Register the matching public key or certificate with the authorization server first, then point the signingKey field at the private key. This method is often required for a confidential client that must not hold a shared secret. Servers such as Okta and Microsoft Entra both support this method.

Review the following table to understand this configuration.
FieldDescription
clientIdRequired client ID that identifies agentgateway at the authorization server.
signingKeyRequired PEM-encoded RSA or EC private key, either the PEM text or {file: <path>}.
assertionAudienceRequired aud claim of the assertion. Most servers require the URL of the token endpoint itself.
algJWS signing algorithm: RS256 (default), RS384, RS512, PS256, ES256, or ES384. The algorithm must match the key family. The RS and PS algorithms need an RSA key, and the ES algorithms need an EC key.
kidOptional kid header that agentgateway stamps on the assertion. Set it when the authorization server registers more than one key for the client.
certificateOptional PEM-encoded X.509 certificate chain, leaf first. Set it for a server that validates the assertion against a certificate rather than a bare key.
certificateHeaderJWS certificate header that agentgateway emits from certificate. Required when certificate is set.

Note

The privateKeyJwt method is not the same as the jwtSign backend authentication method. Both sign a JWT with your private key, and they share the implementation, but privateKeyJwt authenticates agentgateway to the token endpoint, while jwtSign sends a signed JWT to the backend itself.

Two details are worth knowing before you rely on the method.

  • The method names are camelCase here. The Kubernetes custom resources spell the same values in PascalCase, as ClientSecretBasic, ClientSecretPost, and PrivateKeyJwt. Agentgateway rejects the PascalCase spelling.
  • The leaf public key of certificate must match signingKey. A mismatch is logged and does not stop the configuration from loading, so the failure appears as a rejected assertion at the token endpoint rather than as a startup error.

Configuration reference

The following table describes the most common oauthTokenExchange fields. For the full set of fields, see oauthTokenExchange in the API reference for Kubernetes. The two modes take the same fields; standalone spells the enum values in camelCase, such as jwtBearer rather than JwtBearer.

FieldDescription
host, policiesThe token endpoint, referenced as a backend. A host port of 443 automatically enables backend TLS.
pathPath of the token endpoint on the backend. Must start with /. Defaults to /.
grantTypetokenExchange (default, RFC 8693) or jwtBearer (RFC 7523).
clientAuthClient authentication for the token endpoint: clientSecretBasic (default), clientSecretPost, or privateKeyJwt. Omit the field and agentgateway sends no client authentication. See Authenticate the gateway to the token endpoint.
audiences, scopes, resourcesThe audience, scope, and resource parameters sent to the token endpoint. resources are RFC 8707 resource indicators.
subjectTokenWhere to read the incoming token and its token type. Defaults to the Authorization: Bearer header with token type access_token.
actorTokenOptional RFC 8693 delegation actor token (tokenExchange grant only). Has no default source.
authorizationLocationWhere to place the exchanged token in the backend request. Defaults to the Authorization header with a Bearer prefix.
additionalParamsExtra form parameters appended to the token request. Values are CEL expressions.
cacheIn-memory token cache. Defaults to 8192 entries with a 300-second TTL when the response omits expires_in. Set maxEntries: 0 to disable.

Next steps

Cleanup

Stop the gateway with Ctrl+C, then remove the example stack.

docker compose -f examples/traffic-token-exchange/jwt-authz-grant/docker-compose.yaml down
Was this page helpful?
Agentgateway assistant

Ask me anything about agentgateway configuration, features, or usage.

Note: AI-generated content might contain errors; please verify and test all returned information.

Tip: one topic per conversation gives the best results. Use the + button in the chat header to start a new conversation.

Switching topics? Starting a new conversation improves accuracy.
↑↓ navigate ↵ select esc dismiss

What could be improved?

Your feedback helps us improve assistant answers and identify docs gaps we should fix.

Need more help? Join us on Discord: https://discord.gg/y9efgEmppm

Want to use your own agent? Add the Solo MCP server to query our docs directly. Get started here: https://search.solo.io/.