API7 Gateway 3.10.6
Custom plugins are managed per gateway group
Release Date: 2026-08-25
Breaking Changes
Control Plane
-
Custom plugins are managed per gateway group
Upgrade note
A custom plugin used to be a Control Plane wide resource with one copy of the code, so uploading a new build reached every gateway group the plugin was bound to at once. It now belongs to a single gateway group, which changes the API, the permissions, and what an existing deployment looks like after the upgrade.
The endpoints move from
/api/custom_pluginsto/api/gateway_groups/{gateway_group_id}/custom_plugins, and the old paths answerHTTP 410on every method with the replacement path in the message.PUTis create-or-replace keyed by the plugin name, so a pipeline uploads with one call and writes only its own gateway group.gateway_groupsis gone from the request and response payloads, which now carrygateway_group_id.GET /api/pluginsrequires agateway_group_idquery parameter, because the plugin catalog is a per-gateway-group answer.Permissions move with the resource, from
arn:api7:gateway:gatewaysetting/*toarn:api7:gateway:gatewaygroup/{gateway_group_id}.gateway:CreateCustomPluginno longer exists — upload is create-or-replace behind one endpoint, sogateway:UpdateCustomPlugincovers it — and reading a custom plugin now requires the newgateway:GetCustomPluginaction. Reading took no permission at all before, so any signed-in user could read the source of any custom plugin; after the upgrade a role that was never granted custom plugin permissions cannot list or read them and has to be grantedgateway:GetCustomPlugin. An upgrader rewrites stored policies: a statement that granted custom plugin actions on the old resource keeps its other actions there, and the custom plugin actions move to a new statement onarn:api7:gateway:gatewaygroup/*with the same effect and conditions, withgateway:CreateCustomPluginrewritten andgateway:GetCustomPluginadded. The replaced document is kept inpermission_policy_backup.The same upgrader expands each existing plugin into one row per gateway group it was deployed to, so plugins keep working across the upgrade with no action. Two cases need attention afterwards. A plugin that was deployed to no gateway group has nowhere to go and is logged instead of migrated; upload it to the gateway groups that need it. A route that referenced a plugin never deployed to its own gateway group used to be accepted while the plugin never ran; that gateway group now has no such plugin, so the next write to that route reports the plugin as unknown until the stale plugin configuration is removed.
Finally, code uploaded while running 3.10.6 lives only in the new table, so rolling back to an earlier release serves whatever code the pre-upgrade table still holds.
Upgrade Notes
Upgrade note — access log formats naming $http_x_forwarded_* now record what the client sent
The gateway has always overwritten X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Port and cleared Forwarded for requests from peers it does not trust. That work now happens in the NGINX configuration rather than in Lua. What the upstream receives is unchanged, and so is what plugins see: core.request.header and ctx.var.http_x_forwarded_* still return the overwritten values, and ctx.var.original_x_forwarded_* still returns what the client sent.
The NGINX configuration level differs. Naming $http_x_forwarded_proto, $http_x_forwarded_host, $http_x_forwarded_port or $http_forwarded in an access log format, an if, or a map now reads what the client sent, where it previously read the overwritten value. A log format that records $http_x_forwarded_host for auditing therefore starts recording a value the client controls: a request forging X-Forwarded-Host: evil.example.com was logged as the real host before the upgrade and is logged as evil.example.com after it.
Use $scheme, $var_x_forwarded_host and $var_x_forwarded_port to log the overwritten values. $http_x_forwarded_for is not affected. What the client sent is also available in the new $original_x_forwarded_proto, $original_x_forwarded_host, $original_x_forwarded_port, $original_x_forwarded_for and $original_forwarded NGINX variables.
Upgrade note — logging plugins now discard entries once the backlog reaches 8192
The batch processor behind every logging plugin kept undelivered entries in worker memory without a limit unless max_pending_entries was set in the plugin's metadata, so a log server that was slow or unreachable grew the worker's memory with the request rate. The limit now defaults to 8192 entries, and datadog, lago, loggly, sls-logger and the stream subsystem's syslog expose the option for the first time.
A deployment whose log server keeps up is unaffected: the backlog tracks batch_max_size rather than the request rate and stays well under a thousand entries. Once the limit is reached, entries are discarded and reported at most once per second with a running count. If you log request or response bodies larger than a few kilobytes, lower max_pending_entries in the plugin metadata so the backlog fits your memory budget; if you raised batch_max_size, raise this with it.
Upgrade note — OpenID Connect enforces the authorization checks it was configured with
Three checks that could be configured on OpenID Connect (opens in Plugin Hub docs) were not applied in every case, so requests that should have been rejected were let through. Configurations that relied on them are now enforced, and traffic that was passing before may start receiving HTTP 401 or 403.
required_scopes was only applied to tokens validated by introspection. A route using the authorization code flow — the default, with bearer_only unset — never read the scopes granted to the session, so every user who completed the login was authorized. The scopes are now read from the access token, and a session whose granted scopes cannot be determined is denied rather than allowed. Check that your identity provider issues the access token as a JWT carrying scope, or that the ID token carries it, before relying on required_scopes with the authorization code flow.
claim_validator.audience.match_with_client_id compared the aud claim against the client id only when the claim was present, so a token that omitted aud skipped the check. The option now implies that the claim is required, and a token without aud receives HTTP 403.
In bearer_only mode with no valid_issuers configured, the trusted issuer comes from the provider's discovery document; when that fetch failed the gateway logged a warning and verified the token with no issuer constraint at all. It now answers HTTP 401 with error_description="issuer validation unavailable" instead. Configure valid_issuers explicitly if you do not want availability of the discovery endpoint on the request path.
Upgrade note — AI Proxy Multi rejects instances that share a name
AI Proxy Multi (opens in Plugin Hub docs) accepted two instances configured with the same name, which made the instance a request was routed to ambiguous. Such a configuration is now rejected with HTTP 400 at write time.
Configurations already stored keep working but are reported in the Data Plane's configuration compatibility report at error level after the upgrade. Rename the duplicated instances so each name is unique; the report clears once the configuration is rewritten.
Upgrade note — LDAP Auth matches the consumer on the escaped bind DN
LDAP Auth used to look the consumer up by a distinguished name it rebuilt through plain string concatenation, which differs from the DN it actually binds with as soon as the username carries a character that has structural meaning in a DN (, + = < > ; " \). It now uses the escaped DN produced for the bind.
Directories in which every username is free of those characters are unaffected. If a consumer's user_dn was written in the unescaped form to match the previous behavior, rewrite it in the RFC 4514 escaped form — for example cn=comma\,user,ou=users,dc=example,dc=org — or that consumer stops matching after the upgrade.
Upgrade note — Proxy Cache in-memory entries are fetched again once after the upgrade
The storage key layout of Proxy Cache (opens in Plugin Hub docs) in memory strategy changed to close the variant collision described under Fixes, and the cache version was bumped alongside it. Entries written by an earlier release are therefore not reachable under the new layout: the first request for each key is a MISS and repopulates the cache, and a PURGE for a URL that only has pre-upgrade entries answers HTTP 404. The old entries stay in the shared dictionary until their own TTL expires. Deployments using the default disk strategy are unaffected.
Features
Plugins
- GraphQL Limit Count (opens in Plugin Hub docs)
- Added query cost rate limiting, so a GraphQL request consumes quota in proportion to how much work it asks for instead of counting as one request.
cost_strategyselects how the cost is computed —depthfor the selection nesting depth,complexityfor the number of nodes the query resolves, andnode_quantifierfor the number of objects the arguments ask each field to return — andscore_factorscales the result. Settingmax_costrejects a query above that cost withHTTP 403before it reaches the upstream, and the computed value is returned in theX-Graphql-Query-Costresponse header. When the service carries cost decorations, matching them against the upstream schema requires introspecting it, which happens once per service throughintrospection_endpoint, withintrospection_headerscarrying any credential that endpoint requires. Withresolve_variablesenabled, arguments supplied through GraphQL variables are taken into account rather than treated as absent.
- Added query cost rate limiting, so a GraphQL request consumes quota in proportion to how much work it asks for instead of counting as one request.
- Prometheus (opens in Plugin Hub docs)
- Added three metrics for Layer 4 traffic, joining the
apisix_stream_connection_totalcounter that was already exported:apisix_stream_statuscounting completed sessions by termination status, listening address and upstream node,apisix_stream_active_connectionsas a gauge of TCP connections and UDP sessions per listening address, andapisix_stream_bandwidthcounting bytes proxied by listening address, direction and side. Enable the plugin on the stream route to collect them. Thestream_statuskey can be added to thedisabled_labelsplugin metadata to drop labels fromapisix_stream_status, asstatusalready does for the HTTP counter.
- Added three metrics for Layer 4 traffic, joining the
- AI Proxy Multi (opens in Plugin Hub docs)
- Added
fallback_http_statuses, which lists the HTTP status codes an instance may answer with to make the gateway retry the request against the next instance. Previously a fallback only happened when an instance could not be reached at all, so a provider answering401for an expired key or429for a quota exhausted on that account was returned to the client instead of being retried elsewhere.
- Added
- LDAP Auth and LDAP Auth Advanced (opens in Plugin Hub docs)
- Added
hide_credentialsto both plugins, which removes theAuthorizationheader after the client has been authenticated so the directory credentials are not forwarded to the upstream. It defaults tofalse, matching the previous behavior.
- Added
Control Plane
- A custom plugin now belongs to one gateway group instead of the whole deployment, so the same plugin name can run different code in staging and in production and a pipeline that uploads to one environment does not touch the others. See Breaking Changes for the API, permission and migration details.
- GraphQL query costs can be tuned per service through a new
graphql_cost_decorationssub resource on a service. A decoration names a field path such asQuery.productsorProductand adjusts what that field contributes to the cost, either as a flatadd_valueor by multiplying its children by the value of an argument listed inmul_arguments— so a field that returnsfirst: 10objects is costed as ten. Decorations are validated against the service they belong to, a field path can only be decorated once, and changes reach the Data Plane without touching the routes that carry the plugin. - Routes can now match the
PURGEmethod, which is how Proxy Cache (opens in Plugin Hub docs) and GraphQL Proxy Cache (opens in Plugin Hub docs) invalidate a cached entry. The gateway has always accepted it; only the Dashboard's own schema rejected it, so a route serving cache invalidation could not be created through the API or the Dashboard. - Upstream and route timeouts now accept fractional seconds, so
0.5can be configured where the API previously required a whole number. The Data Plane has always supported sub-second timeouts. The smallest value is0.001, because anything below a millisecond is truncated.
Data Plane
- The
X-Forwarded-*headers a request carries are now sanitized by the NGINX configuration instead of by Lua on every request. Plugins see the same values as before, and the values the client sent — already available to Lua asctx.var.original_x_forwarded_*— are now NGINX variables as well, so a log format can record them. See Upgrade Notes for the one place this changes what is logged.
Console (Dashboard)
- Added Traditional Chinese (Taiwan) and Vietnamese. The Chinese locale tags now say which Chinese they mean —
zh-Hans-CNandzh-Hant-TW— so a missing Traditional string falls back to English where it is visible instead of silently rendering Simplified copy. Users who had selected Chinese keep their language. - The license page now shows the Deployment ID as the first row of the license table, with a copy button. The Deployment ID is what you hand to API7 to have a license issued or renewed; it was previously only rendered inside the "Update License" dialog, which is gated on the permission to update a license, so users without that permission could not read it at all. The row is populated even when no license is activated.
Developer Portal
- The portal frontend has been rebuilt on TanStack Start, replacing the Next.js application. Deployment is unchanged — the same image, the same port, and the same
config.yaml— so upgrading needs no configuration change. - An organization invitation that was not accepted can now be resent from the members page, instead of having to be revoked and issued again.
- When only one identity provider is configured, the sign-in page selects it automatically rather than asking the developer to choose from a list of one.
- Organization avatars are now visually distinct from personal avatars in the top bar, so it is clear which organization the current context belongs to.
- The organization identifier field is labelled "Slug", which is what the value is, instead of "URL".
Fixes
Plugins
- GraphQL Limit Count (opens in Plugin Hub docs)
- Fixed issue: A GraphQL document whose fragments spread one another was measured by expanding each spread every time it was referenced, so a small request body could cost an unbounded amount of CPU. A 1.4 KB document with 34 chained fragments held a worker at 100% CPU for over 45 seconds, during which the gateway answered nothing on any route and had to be restarted. Each fragment is now measured once, and a document whose fragment spreads form a cycle is rejected with
HTTP 400.
- Fixed issue: A GraphQL document whose fragments spread one another was measured by expanding each spread every time it was referenced, so a small request body could cost an unbounded amount of CPU. A 1.4 KB document with 34 chained fragments held a worker at 100% CPU for over 45 seconds, during which the gateway answered nothing on any route and had to be restarted. Each fragment is now measured once, and a document whose fragment spreads form a cycle is rejected with
- AI Proxy (opens in Plugin Hub docs)
- Fixed issue: When a streaming upstream answered
HTTP 200withContent-Type: text/event-streamand then ended the response without writing anything, the request was neither answered nor terminated, so the client received no HTTP response at all and the access log recorded a500with a zero-length body. The gateway now returnsHTTP 502.
- Fixed issue: When a streaming upstream answered
- AI Proxy Multi (opens in Plugin Hub docs)
- Fixed issue: When a request was retried against the next instance, it was built from the body the previous attempt had already rewritten, so the previous instance's
options— for example itstemperatureormax_tokens— leaked into the request sent to the fallback and the model name could be overwritten. Each attempt now starts from the client's own request body.
- Fixed issue: When a request was retried against the next instance, it was built from the body the previous attempt had already rewritten, so the previous instance's
- Redirect
- Fixed issue: With
http_to_httpsenabled, theX-Forwarded-Protoheader was compared case-sensitively, so a client or load balancer sendingHTTPSwas redirected to the HTTPS URL it had already reached, producing a redirect loop. The comparison is now case-insensitive.
- Fixed issue: With
- Data Mask (opens in Plugin Hub docs)
- Fixed issue: Request header masking had no effect on a request the gateway answered itself instead of proxying — one rejected by an authentication plugin, a rate limit, or Fault Injection (opens in Plugin Hub docs), for example. Logging plugins running afterwards read the header from the unmodified request, so a credential that was configured to be masked was shipped to the log sink in plaintext. Masking now holds on every path.
- Fixed issue: Removing an element from a JSON array left a hole in it rather than compacting the array, so the elements after the removed one were dropped from the logged body: masking
$.items[1]out of["a","b","c"]logged["a"]instead of["a","c"].
- Proxy Cache (opens in Plugin Hub docs)
- Fixed issue: In
memorystrategy, the storage key of aVaryvariant was the cache key with the variant signature appended, and the cache key is derived from the request URI. A request crafted to carry another request's variant signature in its own URI therefore produced the same storage key, so it was served that request's cached response and its own response was stored where the next request would look that variant up. Storage keys are now derived so that no crafted request can reproduce another one's key. See Upgrade Notes for what this means for entries cached before the upgrade.
- Fixed issue: In
- OpenID Connect (opens in Plugin Hub docs)
- Fixed issue: An identity provider that redirected back with
error=temporarily_unavailable— which the specification defines as a transient condition — made the gateway answerHTTP 500. The authentication flow is now restarted from the original URL, up to three times per session, and other error codes such asaccess_deniedare still treated as a final answer. - Fixed issue:
required_scopes,claim_validator.audience.match_with_client_idand issuer validation were not applied in every case, letting through requests the configuration should have rejected. See Upgrade Notes.
- Fixed issue: An identity provider that redirected back with
- LDAP Auth
- Fixed issue: The consumer was looked up by a distinguished name rebuilt through string concatenation rather than the escaped DN the plugin binds with, so a directory entry whose name contains a DN metacharacter authenticated successfully but matched no consumer — or matched a consumer belonging to a different entry. See Upgrade Notes.
- HMAC Auth (opens in Plugin Hub docs)
- Fixed issue: With
hide_credentialsenabled, theAuthorizationheader was removed from the request sent upstream but stayed in the cached request headers, so a plugin ordered after HMAC Auth on the same route could still read the signature credential.
- Fixed issue: With
- AWS Lambda (opens in Plugin Hub docs)
- Fixed issue: A request body sent with
Transfer-Encoding: chunkedwas forwarded to the function without being reframed, so the invocation failed and the client receivedHTTP 503withfailed to process aws-lambda, err: closedin the error log. The body is now forwarded with aContent-Length. The same applies to the other serverless upstream plugins, which share this code path.
- Fixed issue: A request body sent with
Data Plane
- Fixed issue: When resolving an upstream host that is a CNAME, the resolver's answer was only collapsed onto the queried name when the last record of the answer happened to be of the requested type. A resolver that appends an EDNS(0) OPT record, or returns the answer section out of chain order, defeated that test, so the resolved record was recorded under the canonical name rather than the name that was queried — and a same-type record owned by an unrelated name could be renamed onto the queried name and cached there. The chain is now walked to the name it ends at and only the records that name owns are collapsed.
Control Plane
- Fixed issue: Any signed-in user could list the upstreams of a service, including their node addresses, because the endpoint that lists them carried no permission check while every other view of the same data was filtered by permission. Listing a service's upstreams now requires view permission on that service.
- Fixed issue: Updating a CA certificate or a client certificate did not re-publish the upstreams that referenced it unless they were the service's default upstream, so a non-default upstream kept presenting or trusting the previous certificate until it was edited by hand.
- Fixed issue: An SSL configuration that carried both
cert/keyandcerts/keysfor the same SNI — the way an RSA and an ECDSA certificate are served side by side — was rejected withinput matches more than one oneOf schemas. Both are now accepted together, and the Data Plane negotiates whichever the client prefers. - Fixed issue: A
jwt-authconsumer credential using an asymmetric algorithm was written to the configuration store with a placeholderprivate_keyfield the user never configured, which the Data Plane reported as an unrecognized field and which was stored in the clear. The field is no longer added. Credentials written before the upgrade keep it until they are next saved.
Console (Dashboard)
- Fixed issue: Reopening the plugin editor in YAML mode repeated every field in the suggestion list once per open, so after five opens each field appeared five times. The editor's YAML support is now configured once per page.
Developer Portal
- Fixed issue: Navigating to an organization that does not exist, or that the developer is not a member of, showed an error toast instead of a page explaining what happened.
- Fixed issue: An invitation link and the landing page could redirect a signed-in developer to the wrong place after accepting an invitation.
- Fixed issue: The "Copy page" button in the documentation wrapped onto a second line instead of sitting on the page title's first line.