Skip to main content

Enterprise IdP JWT authentication

Applies to: open APIs with authType = jwt Token source: a JWT access token issued by your enterprise identity provider (IdP); ApiGo only verifies it and never issues it Use cases: organizations that already standardize on Keycloak, Azure AD, Okta, Authing, or another enterprise IdP and want to reuse that identity system for API calls

Authentication overview​

authTypeNameToken sourceRequest headers
noneNo authentication--
app_keySimple authenticationPlatform app credentialX-AppKey
app_secretSignature authenticationPlatform app credentialX-AppKey, X-Timestamp, X-Nonce, X-Sign
oauth2Platform OAuth2Exchanged by the platform with AppKey/AppSecretAuthorization: Bearer <access_token>
jwtEnterprise IdP JWTIssued by the enterprise IdPAuthorization: Bearer <enterprise_token>

JWT verification flow:

  1. The gateway extracts the token from Authorization: Bearer;
  2. It uses the unverified iss only to locate the registered IdP, then verifies the signature exclusively with that IdP's JWKS public keys;
  3. It validates iss/aud/exp/nbf/iat/jti/client_id/typ and other claims;
  4. It resolves the bound app credential by (jwt_idp, jwt_subject);
  5. It reuses the platform's existing app enablement, project isolation, IP whitelist, API authorization, subscription, and rate-limiting chain.

Differences from platform OAuth2:

ItemPlatform OAuth2Enterprise IdP JWT
Token issuerApiGo gateway /oauth2/tokenEnterprise IdP
Caller credentialAppKey / AppSecretIdP client credentials or user authorization
Does ApiGo hold a secret?Yes (AppSecret)No (only IdP public keys)
App identificationappKey claim in the tokenApp binding (jwt_idp, subject)
Error contractHTTP 200 + business codeHTTP 401/403/503 + WWW-Authenticate
MCP note

In the current version the MCP entry point still supports only app_key and platform OAuth2. Enterprise IdP JWT tokens cannot yet be used with /mcp/{appCode}; this will be enabled in phase 2 with iss-based dispatch.

Prerequisites​

Administrator: register the enterprise IdP​

The gateway and admin service share the apigo.jwt.* configuration (either in a Nacos shared config or configured identically on both sides). Minimal example:

apigo:
jwt:
enabled: true
resource: https://api.example.com
clock-skew-seconds: 60
idps:
corp-idp:
display-name: Corporate IdP
issuer: https://idp.example.com/realms/corp
discovery: true
audiences:
- https://api.example.com
token-profile: compat # Keycloak / Azure AD usually need compat
algorithms:
- RS256

Key settings:

SettingDefaultDescription
apigo.jwt.enabledfalseMaster switch; when off, authType=jwt APIs return 503/40030 and the admin UI hides JWT binding
apigo.jwt.resourceemptyResource identifier for documentation and the future RFC 9728 metadata
apigo.jwt.clock-skew-seconds60Global clock skew, overridable per IdP (0–300)
apigo.jwt.max-token-bytes8192Maximum token size in bytes; larger tokens are rejected as malformed
apigo.jwt.forward-identity-headersfalseWhether to inject X-Apigo-* identity headers downstream
apigo.jwt.disable-static-credentials-for-bound-appsfalseWhen on, apps bound to JWT can no longer call with appKey / appSecret (both REST and MCP), preventing static credentials from bypassing IdP governance; oauth2 and jwt are unaffected
apigo.jwt.idps.{key}.issuerrequiredMust exactly match the token's iss and must not be the platform-reserved issuer
apigo.jwt.idps.{key}.discoverytrueResolve jwks_uri from issuer/.well-known/openid-configuration
apigo.jwt.idps.{key}.jwks-uriemptyRequired when discovery=false
apigo.jwt.idps.{key}.allow-httpfalseWhether to allow an intranet IdP over http://
apigo.jwt.idps.{key}.allow-private-networkfalseWhether JWKS may target private network addresses (metadata/link-local addresses are always rejected)
apigo.jwt.idps.{key}.audiencesrequiredAllowed audiences; must intersect the token's aud
apigo.jwt.idps.{key}.token-profilerfc9068rfc9068 strict mode; compat for common IdPs
apigo.jwt.idps.{key}.subject-claimsubSubject claim used for binding and logging
apigo.jwt.idps.{key}.display-claimpreferred_usernameDisplay-name claim; falls back to the subject
apigo.jwt.idps.{key}.scope-claimscopeScope claim name; scp is also supported
apigo.jwt.idps.{key}.algorithmsRS256, ES256Allowed signing algorithms; PS256 and EdDSA are also supported
apigo.jwt.idps.{key}.require-kidtrueWhether the JWS header must contain kid
apigo.jwt.idps.{key}.max-token-lifetime-seconds0Maximum exp - iat; 0 means unlimited
apigo.jwt.idps.{key}.required-claimsemptyAdditional required claims and expected values, e.g. token_use: access
apigo.jwt.jwks.*-JWKS timeouts, cache TTL, rotation cooldown, stale-if-error, and related settings

When enabled=true, invalid configuration blocks startup, including: invalid IdP key, duplicate or invalid issuer, empty audiences, unsupported algorithm, or discovery=false without jwks-uri.

Administrator: bind the app​

JWT authentication requires a binding between an IdP subject and an app credential:

  1. Create or edit a credential under Account Center → App Credentials;
  2. In the JWT identity binding section, select an IdP and enter the subject (defaults to the sub claim and must match the claim string exactly);
  3. IdP and subject must both be filled in or both be empty; providing only one is rejected;
  4. Within a project, one (jwt_idp, jwt_subject) pair can bind to only one app.

You can also maintain the binding through the admin API:

GET /api/box/sys/app/jwt-idps
[
{ "key": "corp-idp", "displayName": "Corporate IdP", "issuer": "https://idp.example.com/realms/corp" }
]
PUT /api/box/sys/app/{appId}/jwt-binding
Content-Type: application/json

{ "jwtIdp": "corp-idp", "jwtSubject": "service-account-order" }

Sending both fields empty unbinds the app. After the binding is written, the Redis reverse index is updated so the gateway can resolve the app directly by (projectId, idpKey, subject).

Administrator: set the API authentication type​

Change the API's authentication type to Enterprise IdP JWT on the API edit page. API-scoped credentials still need authorization for that API; project-scoped credentials are allowed across the project.

Caller: verify token requirements​

  • The token's iss exactly matches the registered issuer;
  • The token's aud contains at least one registered audience;
  • The token is not expired and clock skew is within the allowed range;
  • The token subject matches the bound subject;
  • With token-profile: rfc9068, the token also needs typ=at+jwt, jti, client_id, and iat.

Obtaining a token​

Tokens are issued by your enterprise IdP. The exchange method depends on your IdP and application type. The examples below show generic shapes and are not tied to a specific vendor.

Keycloak (client credentials)​

curl -sS -X POST \
'https://idp.example.com/realms/{realm}/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id={client_id}' \
--data-urlencode 'client_secret={client_secret}'

Keycloak access tokens normally use typ: Bearer, so you usually need:

  • token-profile: compat;
  • an audience mapper on the client so aud contains the audience registered in ApiGo;
  • the client-credentials user or service-account subject as the binding subject.

Azure AD (client credentials)​

curl -sS -X POST \
'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id={client_id}' \
--data-urlencode 'client_secret={client_secret}' \
--data-urlencode 'scope={api-application-id-uri}/.default'

An Azure AD access token normally has the protected API's Application ID URI in aud and typ: JWT, so use token-profile: compat. App-only tokens may have no scope/scp and only roles; in that case set scope-claim: roles, or enforce key claims through required-claims.

User authorization (authorization code)​

To call as an end user, a frontend or an external login flow outside the gateway can obtain an access token through authorization code + PKCE and hand it to the backend for ApiGo calls. Note:

  • The user token's sub must match the bound subject;
  • The browser must never hold an ApiGo AppSecret;
  • The user token's audience, scope, and lifetime must satisfy the IdP registration.

ApiGo does not proxy token exchanges with external IdPs and never accepts enterprise client secrets.

Call examples​

cURL​

#!/usr/bin/env bash
set -euo pipefail

BASE_URL="https://api.example.com"
IDP_TOKEN_URL="https://idp.example.com/realms/corp/protocol/openid-connect/token"
TOKEN="$(curl -sS -X POST "${IDP_TOKEN_URL}" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=${IDP_CLIENT_ID}" \
--data-urlencode "client_secret=${IDP_CLIENT_SECRET}" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')"

curl -sS "${BASE_URL}/open/api/demo?page=1" \
-H "Authorization: Bearer ${TOKEN}"

A failure response looks like:

{
"code": 40029,
"msg": "JWT无效或已过期",
"error": "invalid_token",
"error_description": "the access token is invalid or expired"
}

The response headers also include:

WWW-Authenticate: Bearer realm="apigo", error="invalid_token", error_description="the access token is invalid or expired"

Java (JDK 11+ HttpClient)​

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

public class JwtClientDemo {

private static final String BASE_URL = "https://api.example.com";

public static void main(String[] args) throws Exception {
// Obtain and cache tokens server-side in production; assume the enterprise IdP SDK already provided one here.
String token = System.getenv("IDP_ACCESS_TOKEN");
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/open/api/demo?page=1"))
.header("Authorization", "Bearer " + token)
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
System.out.println(response.statusCode());
System.out.println(response.body());
}
}

Node.js​

const BASE_URL = 'https://api.example.com'

async function callApi(token) {
const res = await fetch(`${BASE_URL}/open/api/demo?page=1`, {
headers: { Authorization: `Bearer ${token}` },
})
const text = await res.text()
if (res.status === 401) {
// Token rejected: obtain a new one from the enterprise IdP and retry once
throw new Error(`token rejected: ${text}`)
}
if (res.status === 403) {
// Subject unbound / app disabled / IP mismatch / API not authorized
throw new Error(`forbidden: ${text}`)
}
if (res.status === 503) {
// IdP unavailable: back off according to Retry-After
throw new Error(`idp unavailable: ${text}`)
}
return text
}

Python (requests)​

import requests

BASE_URL = "https://api.example.com"


def call_api(token: str) -> requests.Response:
return requests.get(
f"{BASE_URL}/open/api/demo",
params={"page": 1},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)


resp = call_api("enterprise_idp_access_token")
if resp.status_code == 401:
# Obtain a new token from the enterprise IdP and retry once
pass
elif resp.status_code == 403:
# Check the app binding, app status, IP whitelist, and API authorization
pass
elif resp.status_code == 503:
# Check Retry-After and back off before retrying
pass

Callers must not retry 401 indefinitely. Refresh the token once; if it still fails, check iss/aud/exp and the binding.

Claims requirements​

General requirements​

ItemRequirement
issMust exactly match the registered issuer (case-sensitive)
audString or array of strings; must intersect the configured audiences
Subject claimDefaults to sub; must contain non-whitespace text, be at most 256 characters in its original form, and contain no control characters. Match exactly, preserving case and surrounding spaces
expRequired numeric Unix timestamp in seconds; exp <= now - skew is expired
nbfOptional; if present, must not be later than now + skew
iatRequired in strict mode; must not be later than now + skew; if max-token-lifetime-seconds is set, exp - iat must not exceed it
jtiRequired in strict mode; at most 256 characters and free of control characters
client_idRequired string in strict mode
ID-token-only claimsnonce, at_hash, and c_hash are rejected to prevent ID-token confusion
Extra claimsWhen configured through required-claims, they must exist with matching values

The two profiles​

Rulerfc9068 (default, strict)compat (common IdPs)
JWS typMust be at+jwt or application/at+jwtJWT, Bearer, at+jwt, or application/at+jwt
iatRequiredOptional
jtiRequiredOptional
client_idRequiredOptional
Access token checkIf token_use is present, it must equal access exactlyIf token_use is present, it must equal access exactly; only when absent may a non-empty scope/scp (or the configured scope claim) be used

Verify access-token markers for each IdP and constrain provider-specific markers using required-claims; a non-empty scope alone does not prove token purpose. Do not accept the IdP login client ID as the resource audience.

JWS header restrictions​

  • The algorithm must be one of the IdP's configured algorithms: RS256, ES256, PS256, or EdDSA;
  • none and every HS* algorithm are rejected;
  • kid is required by default (require-kid: true);
  • jku, x5u, x5c, jwk, crit, and zip are rejected to prevent external key injection;
  • b64: false (RFC 7797 unencoded payload) is rejected.

Scope behavior​

The MVP only parses and records scopes; they do not participate in API authorization. API permissions are still determined by platform authorization and subscriptions. Error code 40032 is reserved for future scope enforcement.

Error codes and troubleshooting​

JWT APIs use standard HTTP semantics. The response body still contains the platform code/msg and additionally carries RFC 6750 error/error_description:

HTTPcodeerrorMeaningTroubleshooting
40140028-Missing Authorization or Bearer tokenAdd the header; confirm the Bearer prefix
40040029invalid_requestMalformed Authorization header, multiple Authorization headers, or token too largeKeep a single header; check whether the token exceeds 8192 bytes
40140029invalid_tokenInvalid signature, iss, aud, exp, typ, kid, or claimsCheck every item in Claims requirements; confirm the token comes from a registered IdP
40340031-(jwt_idp, subject) is not bound to any app, or the index and app disagreeConfigure JWT identity binding on the credential; confirm the subject matches exactly
40340019-App disabledEnable the app credential
40340002-API not authorized, or a project-scoped credential called across projectsAdd a subscription/authorization or use a credential from the same project
40340018-Source IP is not in the app whitelistAdd the egress IP or adjust the whitelist
40340032-Insufficient scope (reserved)Check the scopes issued by the IdP
50340030-JWT disabled, or IdP/JWKS unavailableCheck apigo.jwt.enabled, IdP reachability, and JWKS configuration; retry after Retry-After
50350001-Redis/cache or another internal dependency failedRetry later and check the gateway logs

Additional notes:

  • 503 responses include Retry-After: 5;
  • Routing-layer errors such as API-not-found or API-offline are evaluated before authentication and still return HTTP 200 + business code (40001 / 40006), consistent with the existing contract;
  • WWW-Authenticate carries only realm and the RFC-defined error/error_description; internal exception details are never exposed.

Typical troubleshooting order:

  1. Use IdP introspection or a debugger to confirm the token is unexpired and iss/aud are correct;
  2. Decode the token and compare the subject with the binding value character by character (watch for spaces and case);
  3. Confirm the API uses jwt, the app is enabled, and the IP is whitelisted;
  4. On 503, check gateway-to-IdP connectivity and the JWKS URL;
  5. If failures started right after key rotation, wait for the cache refresh or confirm the kid is present in JWKS.

Key rotation and clock requirements​

  • JWKS cache: TTL defaults to 600 seconds; when refresh fails, old public keys remain usable within stale-ttl-seconds (24 hours by default);
  • Active refresh: an unknown kid triggers one active refresh, with a 30-second cooldown to protect the IdP from token floods;
  • Rotation practice: publish the new key first, keep old and new keys available for at least one token lifetime, then retire the old key; keep kid stable and unique per rotation;
  • Clock: 60 seconds of skew is allowed by default and can be tuned per IdP (0–300); callers and IdPs should both use NTP;
  • Token lifetime: keep access tokens at 15 minutes or less; use max-token-lifetime-seconds when a hard cap is needed.

Security recommendations​

  1. HTTPS everywhere: the issuer must be https:// by default; only intranet IdPs may explicitly enable allow-http;
  2. Trust only configured key sources: jku/x5u/x5c/jwk are rejected, redirects are disabled by default, and private-network JWKS requires explicit opt-in;
  3. Short-lived tokens: let the IdP control lifetime, refresh on demand, and never store tokens long-term;
  4. No tokens in logs or URLs: send them only in the Authorization header so proxies and access logs cannot capture them;
  5. Least-privilege binding: bind each subject to only the apps it needs; unbind promptly when an employee leaves or a service account is retired;
  6. Audience isolation: use different audiences for different environments and APIs to prevent cross-environment token reuse;
  7. Downstream isolation: after successful JWT authentication, the gateway strips Authorization, X-AppKey, X-Timestamp, X-Nonce, X-Sign, X-User-Id, X-User-Name, X-Create-By, and X-Apigo-* headers; identity headers are not injected downstream by default, preventing token passthrough and identity spoofing;
  8. Error handling: on 401, refresh the token once and retry; on 403, do not retry—check the binding and authorization; on 503, back off according to Retry-After.

Quick reference​

1. Administrator registers IdP: apigo.jwt.enabled=true + idps.{key}.issuer/audiences
2. Bind the app: credential edit page, JWT identity binding = (IdP, subject)
3. Set the API authentication type: Enterprise IdP JWT
4. Caller obtains a token from the enterprise IdP (client credentials / authorization code)
5. Call with: Authorization: Bearer <enterprise_token>
6. Verification: iss + aud + exp + signature (JWKS) + subject binding
7. On success: platform app enablement / project isolation / IP / authorization / rate limiting apply
8. On failure: 401 invalid_token -> refresh token; 403 -> check binding/authorization/IP; 503 -> back off and retry