Enterprise IdP JWT authentication
Applies to: open APIs with
authType = jwtToken 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
| authType | Name | Token source | Request headers |
|---|---|---|---|
none | No authentication | - | - |
app_key | Simple authentication | Platform app credential | X-AppKey |
app_secret | Signature authentication | Platform app credential | X-AppKey, X-Timestamp, X-Nonce, X-Sign |
oauth2 | Platform OAuth2 | Exchanged by the platform with AppKey/AppSecret | Authorization: Bearer <access_token> |
jwt | Enterprise IdP JWT | Issued by the enterprise IdP | Authorization: Bearer <enterprise_token> |
JWT verification flow:
- The gateway extracts the token from
Authorization: Bearer; - It uses the unverified
issonly to locate the registered IdP, then verifies the signature exclusively with that IdP's JWKS public keys; - It validates
iss/aud/exp/nbf/iat/jti/client_id/typand other claims; - It resolves the bound app credential by
(jwt_idp, jwt_subject); - It reuses the platform's existing app enablement, project isolation, IP whitelist, API authorization, subscription, and rate-limiting chain.
Differences from platform OAuth2:
| Item | Platform OAuth2 | Enterprise IdP JWT |
|---|---|---|
| Token issuer | ApiGo gateway /oauth2/token | Enterprise IdP |
| Caller credential | AppKey / AppSecret | IdP client credentials or user authorization |
| Does ApiGo hold a secret? | Yes (AppSecret) | No (only IdP public keys) |
| App identification | appKey claim in the token | App binding (jwt_idp, subject) |
| Error contract | HTTP 200 + business code | HTTP 401/403/503 + WWW-Authenticate |
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:
| Setting | Default | Description |
|---|---|---|
apigo.jwt.enabled | false | Master switch; when off, authType=jwt APIs return 503/40030 and the admin UI hides JWT binding |
apigo.jwt.resource | empty | Resource identifier for documentation and the future RFC 9728 metadata |
apigo.jwt.clock-skew-seconds | 60 | Global clock skew, overridable per IdP (0–300) |
apigo.jwt.max-token-bytes | 8192 | Maximum token size in bytes; larger tokens are rejected as malformed |
apigo.jwt.forward-identity-headers | false | Whether to inject X-Apigo-* identity headers downstream |
apigo.jwt.disable-static-credentials-for-bound-apps | false | When 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}.issuer | required | Must exactly match the token's iss and must not be the platform-reserved issuer |
apigo.jwt.idps.{key}.discovery | true | Resolve jwks_uri from issuer/.well-known/openid-configuration |
apigo.jwt.idps.{key}.jwks-uri | empty | Required when discovery=false |
apigo.jwt.idps.{key}.allow-http | false | Whether to allow an intranet IdP over http:// |
apigo.jwt.idps.{key}.allow-private-network | false | Whether JWKS may target private network addresses (metadata/link-local addresses are always rejected) |
apigo.jwt.idps.{key}.audiences | required | Allowed audiences; must intersect the token's aud |
apigo.jwt.idps.{key}.token-profile | rfc9068 | rfc9068 strict mode; compat for common IdPs |
apigo.jwt.idps.{key}.subject-claim | sub | Subject claim used for binding and logging |
apigo.jwt.idps.{key}.display-claim | preferred_username | Display-name claim; falls back to the subject |
apigo.jwt.idps.{key}.scope-claim | scope | Scope claim name; scp is also supported |
apigo.jwt.idps.{key}.algorithms | RS256, ES256 | Allowed signing algorithms; PS256 and EdDSA are also supported |
apigo.jwt.idps.{key}.require-kid | true | Whether the JWS header must contain kid |
apigo.jwt.idps.{key}.max-token-lifetime-seconds | 0 | Maximum exp - iat; 0 means unlimited |
apigo.jwt.idps.{key}.required-claims | empty | Additional 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:
- Create or edit a credential under Account Center → App Credentials;
- In the JWT identity binding section, select an IdP and enter the subject (defaults to the
subclaim and must match the claim string exactly); - IdP and subject must both be filled in or both be empty; providing only one is rejected;
- 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
issexactly matches the registered issuer; - The token's
audcontains 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 needstyp=at+jwt,jti,client_id, andiat.
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
audcontains 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
submust 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
| Item | Requirement |
|---|---|
iss | Must exactly match the registered issuer (case-sensitive) |
aud | String or array of strings; must intersect the configured audiences |
| Subject claim | Defaults 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 |
exp | Required numeric Unix timestamp in seconds; exp <= now - skew is expired |
nbf | Optional; if present, must not be later than now + skew |
iat | Required in strict mode; must not be later than now + skew; if max-token-lifetime-seconds is set, exp - iat must not exceed it |
jti | Required in strict mode; at most 256 characters and free of control characters |
client_id | Required string in strict mode |
| ID-token-only claims | nonce, at_hash, and c_hash are rejected to prevent ID-token confusion |
| Extra claims | When configured through required-claims, they must exist with matching values |
The two profiles
| Rule | rfc9068 (default, strict) | compat (common IdPs) |
|---|---|---|
JWS typ | Must be at+jwt or application/at+jwt | JWT, Bearer, at+jwt, or application/at+jwt |
iat | Required | Optional |
jti | Required | Optional |
client_id | Required | Optional |
| Access token check | If token_use is present, it must equal access exactly | If 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, orEdDSA; noneand everyHS*algorithm are rejected;kidis required by default (require-kid: true);jku,x5u,x5c,jwk,crit, andzipare 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:
| HTTP | code | error | Meaning | Troubleshooting |
|---|---|---|---|---|
| 401 | 40028 | - | Missing Authorization or Bearer token | Add the header; confirm the Bearer prefix |
| 400 | 40029 | invalid_request | Malformed Authorization header, multiple Authorization headers, or token too large | Keep a single header; check whether the token exceeds 8192 bytes |
| 401 | 40029 | invalid_token | Invalid signature, iss, aud, exp, typ, kid, or claims | Check every item in Claims requirements; confirm the token comes from a registered IdP |
| 403 | 40031 | - | (jwt_idp, subject) is not bound to any app, or the index and app disagree | Configure JWT identity binding on the credential; confirm the subject matches exactly |
| 403 | 40019 | - | App disabled | Enable the app credential |
| 403 | 40002 | - | API not authorized, or a project-scoped credential called across projects | Add a subscription/authorization or use a credential from the same project |
| 403 | 40018 | - | Source IP is not in the app whitelist | Add the egress IP or adjust the whitelist |
| 403 | 40032 | - | Insufficient scope (reserved) | Check the scopes issued by the IdP |
| 503 | 40030 | - | JWT disabled, or IdP/JWKS unavailable | Check apigo.jwt.enabled, IdP reachability, and JWKS configuration; retry after Retry-After |
| 503 | 50001 | - | Redis/cache or another internal dependency failed | Retry 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-Authenticatecarries onlyrealmand the RFC-definederror/error_description; internal exception details are never exposed.
Typical troubleshooting order:
- Use IdP introspection or a debugger to confirm the token is unexpired and
iss/audare correct; - Decode the token and compare the subject with the binding value character by character (watch for spaces and case);
- Confirm the API uses
jwt, the app is enabled, and the IP is whitelisted; - On 503, check gateway-to-IdP connectivity and the JWKS URL;
- If failures started right after key rotation, wait for the cache refresh or confirm the
kidis 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
kidtriggers 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
kidstable 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-secondswhen a hard cap is needed.
Security recommendations
- HTTPS everywhere: the issuer must be
https://by default; only intranet IdPs may explicitly enableallow-http; - Trust only configured key sources:
jku/x5u/x5c/jwkare rejected, redirects are disabled by default, and private-network JWKS requires explicit opt-in; - Short-lived tokens: let the IdP control lifetime, refresh on demand, and never store tokens long-term;
- No tokens in logs or URLs: send them only in the
Authorizationheader so proxies and access logs cannot capture them; - Least-privilege binding: bind each subject to only the apps it needs; unbind promptly when an employee leaves or a service account is retired;
- Audience isolation: use different audiences for different environments and APIs to prevent cross-environment token reuse;
- 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, andX-Apigo-*headers; identity headers are not injected downstream by default, preventing token passthrough and identity spoofing; - 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