Skip to main content

Token exchange at the API gateway: translate partner OIDC tokens to backend JWTs without changing your services

How to use Zerq's jwt_node to verify partner OIDC tokens, extract claims, and mint internal JWTs at the gateway layer. No code changes to backend services.

  • how-to
  • security
  • jwt
  • oidc
  • workflow-builder
  • api-management
Zerq team

Enterprise API platforms that serve B2B partners run into the same structural problem sooner or later. External partners authenticate with OIDC tokens issued by their own identity provider (Keycloak, Azure AD, PingFederate) and those tokens carry their IdP's issuer, their own audience claim, and their own claim format. Meanwhile, the internal backend services expect a different JWT: one signed by an internal issuer, with internal role and tenant claims, and a short expiry tied to the platform's rotation policy.

Something has to translate one into the other. API gateway token exchange is the piece of infrastructure responsible for that translation, and it is either scattered across every backend service, delegated to a dedicated Security Token Service that becomes another component to operate, or simply skipped in favour of per-service OIDC validation with no central record of what happened. None of those options are clean, and two of them create compliance gaps.

Zerq's workflow builder includes a jwt_node that handles three JWT operations in a single workflow step: decode, verify, and sign. This post walks through how to wire a token exchange workflow in Zerq: verify a partner's OIDC token against their JWKS endpoint, extract the relevant claims, mint an internal JWT, and forward the enriched request to your backend, all without modifying a line of service code.

Why the common alternatives fall short

The naive approach is to put OIDC token validation in each backend service. The service fetches the JWKS from the partner's IdP, validates the signature and claims, and does its own claim mapping. This creates three problems. First, every service duplicates auth logic, and when a partner rotates their JWKS, every service needs updating. Second, the audit trail is fragmented: each service logs in its own format, and stitching a complete picture of what a partner accessed requires querying multiple services. Third, claim mapping is inconsistent: one service maps the partner's sub to user_id, another maps it to external_id, and the data model drifts.

Centralising the exchange in a dedicated Security Token Service (STS) solves the duplication problem but introduces a new component to deploy, scale, and maintain. The STS becomes a critical path dependency: if it is unavailable, no partner request succeeds. It also requires its own authentication, rate limiting, and audit configuration. You are essentially building a second gateway in front of your first one.

Kong supports JWT validation via a plugin, but has no native mechanism to then mint a new JWT with transformed claims in the same request pipeline. AWS API Gateway and Azure APIM both rely on Lambda authorizers or policy expressions to handle token exchange, which means custom code that needs to be tested, versioned, and deployed separately from gateway configuration.

How Zerq's jwt_node handles token exchange

Zerq's workflow builder exposes a jwt_node with three operations: decode (inspect claims without validating the signature), verify (validate the signature and extract claims), and sign (mint a new JWT with specified claims and expiry). The three operations can be combined in a single workflow to build a complete token exchange pipeline.

The gateway-level token exchange flow for a partner OIDC request looks like this:

  1. Partner sends a request with Authorization: Bearer <oidc_token> and the standard X-Client-ID / X-Profile-ID headers that Zerq uses for access control.
  2. Zerq authenticates the client using the profile's configured auth method (OIDC profile verifies the token against the partner's IdP).
  3. The workflow then picks up the raw token and verifies it again, with explicit JWKS validation against the partner's endpoint, to extract claims for claim mapping.
  4. A set_node builds the internal JWT claims object from the verified payload.
  5. A second jwt_node in sign mode mints the internal JWT.
  6. An http_request_node forwards the original request to the backend, replacing the Authorization header with the new internal JWT.
  7. The backend receives a request carrying only the internal JWT and never sees the partner OIDC token.

Every request in this flow is captured in Zerq's request logs with the client ID, profile ID, endpoint, status, and latency. The token exchange itself is not a separate audit event; it is part of the same request record.

Building the workflow: step by step

Step 1: Create the collection and proxy

  1. In the management UI, go to Collections and open or create the collection that contains the partner-facing API.
  2. Open the relevant Proxy (the route that partners call).
  3. Set the proxy target to your backend service URL. Do not attach a credential here; the workflow will handle the downstream auth header.

Step 2: Open the workflow builder

Click Edit Workflow on the proxy. The builder opens with a default http_trigger → response_node canvas.

Step 3: Add the OIDC verify node

Click Add node and search for jwt. Place a jwt_node after the http_trigger.

Configure it:

{
  "id": "verify_partner_token",
  "type": "jwt_node",
  "config": {
    "operation": "verify",
    "method": "jwks",
    "jwks_url": "https://partner-idp.example.com/.well-known/jwks.json",
    "issuer": "https://partner-idp.example.com",
    "audience": "platform-api"
  },
  "inputs": {
    "token": "{{ $json['http_trigger'].request.headers['authorization'][0] }}"
  }
}

The node reads the Authorization header from the incoming request, strips the Bearer prefix automatically, fetches the public key matching the token's kid from the JWKS endpoint, and validates the signature, issuer, and audience. On success it returns matched_output: "valid" and a payload object containing all token claims. On failure it returns matched_output: "invalid".

Step 4: Wire the invalid path to a 401 response

Connect the invalid output handle of verify_partner_token to a response_node configured to return a 401:

{
  "id": "reject_response",
  "type": "response_node",
  "config": {
    "status": 401,
    "headers": { "Content-Type": "application/json" },
    "body": "{\"error\": \"invalid_token\", \"message\": \"Partner token validation failed\"}"
  }
}

This ensures that a malformed, expired, or wrong-issuer token is rejected at the gateway before touching any backend service.

Step 5: Build the internal JWT claims

Connect the valid output to a set_node. This node constructs the claims object that the internal JWT will carry:

{
  "id": "build_internal_claims",
  "type": "set_node",
  "config": {
    "assignments": {
      "sub": "{{ $json['verify_partner_token'].payload.sub }}",
      "partner_id": "{{ $json['verify_partner_token'].payload.sub }}",
      "iss": "https://internal.platform.com",
      "aud": "backend-services",
      "scope": "{{ $json['verify_partner_token'].payload.scope || 'read' }}"
    }
  }
}

You map whichever partner claims your backends need. Add or remove fields to match your internal JWT contract. The iss and aud values become fixed internal constants; the backend never sees the partner IdP's issuer URL.

Step 6: Sign the internal JWT

Add a second jwt_node in sign mode:

{
  "id": "sign_internal_jwt",
  "type": "jwt_node",
  "config": {
    "operation": "sign",
    "algorithm": "HS256",
    "secret": "$INTERNAL_JWT_SECRET",
    "expiry_seconds": 900
  },
  "inputs": {
    "claims": "{{ $json['build_internal_claims'] }}"
  }
}

The expiry_seconds: 900 configuration tells the jwt_node to automatically add exp (15 minutes from now) and iat (issued at) claims. The $INTERNAL_JWT_SECRET value references an environment variable injected into the gateway process, so the signing secret never appears in the UI or the database. The node returns { "token": "<signed_jwt_string>" }.

Step 7: Forward the request with the internal JWT

Add an http_request_node as the backend call:

{
  "id": "call_backend",
  "type": "http_request_node",
  "config": {
    "timeout_ms": 10000,
    "retry_config": { "max_attempts": 2, "backoff_ms": 250 }
  },
  "inputs": {
    "url": "https://backend.internal/{{ $json['http_trigger'].request.path }}",
    "method": "{{ $json['http_trigger'].request.method }}",
    "headers": {
      "Authorization": ["Bearer {{ $json['sign_internal_jwt'].token }}"],
      "Content-Type": ["application/json"]
    },
    "body": {
      "type": "json",
      "content": "{{ $json['http_trigger'].request.body }}"
    }
  }
}

The backend receives the original request path and body, but with a fresh internal JWT in the Authorization header. The partner's OIDC token is not forwarded.

Step 8: Return the backend response

Connect the success output of call_backend to a response_node:

{
  "id": "success_response",
  "type": "response_node",
  "config": {
    "status": "{{ $json['call_backend'].response.status_code }}",
    "body": "{{ JSON.stringify($json['call_backend'].response.body) }}"
  }
}

Connect the error output to a separate response_node returning a 502 with a structured error body.

Step 9: Validate before saving

Click Validate in the toolbar. The builder confirms all edges are wired and all required config fields are present. Click Save to activate the workflow.

What partners send, what backends receive

Here is the complete picture of what moves through the gateway on each request.

Partner sends:

GET /orders/12345 HTTP/1.1
Host: api.platform.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InBhcnRuZXIta2V5LTEifQ...
X-Client-ID: acme-corp
X-Profile-ID: prod-oidc

Backend receives:

GET /orders/12345 HTTP/1.1
Host: backend.internal
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Where the backend JWT payload decodes to:

{
  "sub": "acme-corp-service-account",
  "partner_id": "acme-corp-service-account",
  "iss": "https://internal.platform.com",
  "aud": "backend-services",
  "scope": "orders:read orders:write",
  "iat": 1754812800,
  "exp": 1754813700
}

The backend service validates this JWT using the shared internal secret, confirms the issuer and audience match its expectations, and processes the request. It knows the caller is acme-corp-service-account with the given scope, but never learns which IdP issued the original OIDC token or how the partner authenticated.

Audit trail for the token exchange

Every request through this workflow is captured in Zerq's request log, regardless of whether the token exchange succeeded or failed. A typical log entry for a successful exchange looks like:

{
  "request_id": "req_01jxkqp7m8v3nz",
  "timestamp": "2026-08-10T11:23:44.812Z",
  "method": "GET",
  "path": "/orders/12345",
  "target_endpoint": "https://backend.internal/orders/12345",
  "status_code": 200,
  "latency_ms": 48,
  "client_id": "acme-corp",
  "profile_id": "prod-oidc",
  "collection": "partner-orders-api",
  "client_ip": "203.0.113.55"
}

A failed exchange (partner sends an expired or wrong-issuer token) produces:

{
  "request_id": "req_01jxkqp8a2m5qr",
  "timestamp": "2026-08-10T11:23:51.204Z",
  "method": "GET",
  "path": "/orders/12345",
  "target_endpoint": null,
  "status_code": 401,
  "latency_ms": 12,
  "client_id": "acme-corp",
  "profile_id": "prod-oidc",
  "collection": "partner-orders-api",
  "client_ip": "203.0.113.55"
}

Filter by client_id = acme-corp and status_code = 401 to investigate failed auth attempts for a specific partner. Filter by collection = partner-orders-api and status_code = 2xx to produce a complete partner activity report for compliance purposes. The same log fields work for AI agent traffic and human operator traffic; there is no separate audit stream to correlate.

Compliance teams with the Auditor role can query these logs directly in the management UI without any admin permissions. They can also export to a SIEM through the audit log API endpoint.

What this looks like in practice

A European fintech operating open banking APIs runs this pattern for its TPP (Third Party Provider) integrations. Each TPP has its own OIDC identity provider, registered with the platform during onboarding. The platform manages a Zerq client per TPP, with the OIDC profile configured to the TPP's issuer URL and audience claim.

Before Zerq, each backend microservice (accounts, transactions, payment initiation) implemented its own JWKS fetch, signature validation, and claim mapping. When one TPP rotated their JWKS keys (a routine quarterly event), the platform team had to coordinate changes across seven services and verify that all of them had updated their key cache before the old keys expired. Any gap caused 401 errors for live TPP traffic.

After moving the exchange to a Zerq workflow, key rotation requires updating the jwks_url in the verify_partner_token node configuration: a single change in the management UI, audited by timestamp and actor. All seven backend services continue using the shared internal JWT format and never know a key rotation happened. The workflow validates the incoming token against whichever key the JWKS endpoint currently publishes, so the gateway always fetches the current key set.

The fintech's compliance team queries the Zerq request logs to produce the TPP access reports required under PSD2, filtered by client ID per TPP, date-ranged per reporting period, and exportable to their existing SIEM. All from the same log that records the internal system traffic.

What you get from this approach

A jwt_node workflow gives you a single, audited point of token exchange for all partner traffic. Claim mapping is declarative and version-controlled as part of workflow configuration, not scattered across service code. JWKS rotation is a management plane change, not a code deployment. The internal JWT format is a stable contract your backend services can rely on regardless of which partner IdP issued the upstream token.

The same gateway that handles this token exchange enforces rate limits per partner, routes traffic to backend services, and records every request in the same audit log used by your compliance team. There is no second system for token exchange and no separate audit stream to correlate.


Zerq is an enterprise API gateway built for regulated industries — one platform for API management, AI agent access, compliance audit, and developer portal, running entirely in your own infrastructure. See how it works or request a demo to walk through your specific requirements.