This is an automated email from the ASF dual-hosted git repository.

nic-6443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new c22058ce5 feat: extend secret references to all plugins with central 
resolution (#13312)
c22058ce5 is described below

commit c22058ce5bbf0e4fb04ce102e77fd525eedccfdb
Author: Nic <[email protected]>
AuthorDate: Thu Apr 30 18:33:07 2026 +0800

    feat: extend secret references to all plugins with central resolution 
(#13312)
---
 apisix-master-0.rockspec                     |   2 +-
 apisix/core/schema.lua                       |   7 +-
 apisix/plugin.lua                            |  68 +++++
 apisix/plugins/ai-aws-content-moderation.lua |   6 -
 apisix/plugins/ai-proxy-multi.lua            |   3 +-
 apisix/plugins/ai-proxy.lua                  |   3 +-
 apisix/plugins/authz-keycloak.lua            |   3 -
 apisix/plugins/clickhouse-logger.lua         |   3 -
 apisix/plugins/jwt-auth.lua                  |   3 +-
 apisix/plugins/limit-count.lua               |   2 -
 apisix/plugins/openid-connect.lua            |   7 +-
 apisix/plugins/proxy-rewrite.lua             |  17 +-
 apisix/plugins/redirect.lua                  |  19 +-
 apisix/plugins/request-validation.lua        |   5 +-
 apisix/plugins/response-rewrite.lua          |   3 +-
 apisix/schema_def.lua                        |  36 +--
 apisix/secret.lua                            |  73 ++++++
 docs/en/latest/terminology/secret.md         |  16 +-
 docs/zh/latest/terminology/secret.md         |  16 +-
 t/admin/ssl.t                                |   2 +-
 t/secret/central-secret-refs.t               | 376 +++++++++++++++++++++++++++
 21 files changed, 599 insertions(+), 71 deletions(-)

diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index 0932be09d..e92266db4 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -52,7 +52,7 @@ dependencies = {
     "lua-resty-openidc = 1.8.0-1",
     "luafilesystem = 1.8.0-1",
     "nginx-lua-prometheus-api7 = 0.20250302-1",
-    "jsonschema = 0.9.9-0",
+    "jsonschema = 0.9.13-0",
     "lua-resty-ipmatcher = 0.6.1-0",
     "lua-resty-kafka = 0.23-0",
     "lua-resty-logger-socket = 2.0.3-0",
diff --git a/apisix/core/schema.lua b/apisix/core/schema.lua
index 9a50d93b7..fd6fa79ae 100644
--- a/apisix/core/schema.lua
+++ b/apisix/core/schema.lua
@@ -24,6 +24,7 @@ local lrucache = require("apisix.core.lrucache")
 local schema_def = require("apisix.schema_def")
 local cached_validator = lrucache.new({count = 1000, ttl = 0})
 local pcall = pcall
+local require = require
 local error = error
 
 local _M = {
@@ -39,7 +40,10 @@ local function create_validator(schema)
     -- local file2=io.output("/tmp/2.txt")
     -- file2:write(code)
     -- file2:close()
-    local ok, res = pcall(jsonschema.generate_validator, schema)
+    local secret = require("apisix.secret")
+    local ok, res = pcall(jsonschema.generate_validator, schema, {
+        skip_validation = secret.is_secret_ref,
+    })
     if ok then
         return res
     end
@@ -58,6 +62,7 @@ local function get_validator(schema)
     return validator, nil
 end
 
+
 function _M.check(schema, json)
     local validator, err = get_validator(schema)
 
diff --git a/apisix/plugin.lua b/apisix/plugin.lua
index b69ba33a8..6c89a034b 100644
--- a/apisix/plugin.lua
+++ b/apisix/plugin.lua
@@ -21,6 +21,7 @@ local enable_debug  = require("apisix.debug").enable_debug
 local wasm          = require("apisix.wasm")
 local expr          = require("resty.expr.v1")
 local apisix_ssl    = require("apisix.ssl")
+local secret        = require("apisix.secret")
 
 local ngx           = ngx
 local ngx_ok        = ngx.OK
@@ -65,6 +66,56 @@ local merge_global_rule_lrucache = core.lrucache.new({
     ttl = 300, count = 512
 })
 
+-- Cache for resolved plugin confs: original_conf -> {resolved, secret_vals}
+-- Weak keys ensure entries are GC'd when original conf is replaced (config 
reload)
+-- Weak-keyed cache: original_conf -> {resolved, secret_vals}.
+-- Avoids deepcopy on every request when secret values haven't changed,
+-- which preserves plugins' internal caches that use conf table identity
+-- as cache key (e.g. ai-rate-limiting's limit_conf_cache).
+local _resolved_cache = setmetatable({}, {__mode = "k"})
+local _no_secret_ref = setmetatable({}, {__mode = "k"})
+
+local function vals_equal(a, b)
+    if a == b then
+        return true
+    end
+    if not a or not b then
+        return false
+    end
+    for k, v in pairs(a) do
+        if b[k] ~= v then
+            return false
+        end
+    end
+    for k in pairs(b) do
+        if a[k] == nil then
+            return false
+        end
+    end
+    return true
+end
+
+local function resolve_plugin_conf(conf)
+    if _no_secret_ref[conf] then
+        return conf
+    end
+    if not secret.has_secret_ref(conf) then
+        _no_secret_ref[conf] = true
+        return conf
+    end
+
+    local current_vals = secret.collect_secret_values(conf, true)
+
+    local cached = _resolved_cache[conf]
+    if cached and vals_equal(cached.secret_vals, current_vals) then
+        return cached.resolved
+    end
+
+    local resolved = secret.fetch_secrets(conf, true)
+    _resolved_cache[conf] = {resolved = resolved, secret_vals = current_vals}
+    return resolved
+end
+
 local local_conf
 local check_plugin_metadata
 
@@ -575,6 +626,14 @@ function _M.filter(ctx, conf, plugins, route_conf, phase)
         core.tablepool.release("tmp_plugin_confs", tmp_plugin_confs)
     end
 
+    -- resolve $secret:// and $env:// references in plugin confs
+    for i = 2, #plugins, 2 do
+        local resolved = resolve_plugin_conf(plugins[i])
+        if resolved ~= plugins[i] then
+            plugins[i] = resolved
+        end
+    end
+
     return plugins
 end
 
@@ -600,6 +659,14 @@ function _M.stream_filter(user_route, plugins)
 
     trace_plugins_info_for_debug(nil, plugins)
 
+    -- resolve $secret:// and $env:// references in stream plugin confs
+    for i = 2, #plugins, 2 do
+        local resolved = resolve_plugin_conf(plugins[i])
+        if resolved ~= plugins[i] then
+            plugins[i] = resolved
+        end
+    end
+
     return plugins
 end
 
@@ -902,6 +969,7 @@ function _M.conf_version(conf)
 end
 
 
+
 local function check_single_plugin_schema(name, plugin_conf, schema_type, 
skip_disabled_plugin)
     if type(plugin_conf) ~= "table" then
         return false, "invalid plugin conf " ..
diff --git a/apisix/plugins/ai-aws-content-moderation.lua 
b/apisix/plugins/ai-aws-content-moderation.lua
index 2cf45d6d2..bd3cbf109 100644
--- a/apisix/plugins/ai-aws-content-moderation.lua
+++ b/apisix/plugins/ai-aws-content-moderation.lua
@@ -21,7 +21,6 @@ local aws = require("resty.aws")
 local aws_instance
 
 local http = require("resty.http")
-local fetch_secrets = require("apisix.secret").fetch_secrets
 
 local pairs = pairs
 local unpack = unpack
@@ -88,11 +87,6 @@ end
 
 
 function _M.rewrite(conf, ctx)
-    conf = fetch_secrets(conf, true)
-    if not conf then
-        return HTTP_INTERNAL_SERVER_ERROR, "failed to retrieve secrets from 
conf"
-    end
-
     local body, err = core.request.get_body()
     if not body then
         return HTTP_BAD_REQUEST, err
diff --git a/apisix/plugins/ai-proxy-multi.lua 
b/apisix/plugins/ai-proxy-multi.lua
index a21c477eb..29f76b045 100644
--- a/apisix/plugins/ai-proxy-multi.lua
+++ b/apisix/plugins/ai-proxy-multi.lua
@@ -16,6 +16,7 @@
 --
 
 local core = require("apisix.core")
+local secret = require("apisix.secret")
 local schema = require("apisix.plugins.ai-proxy.schema")
 local base   = require("apisix.plugins.ai-proxy.base")
 local plugin = require("apisix.plugin")
@@ -110,7 +111,7 @@ function _M.check_schema(conf)
             return false, "ai provider: " .. instance.provider .. " is not 
supported."
         end
         local sa_json = core.table.try_read_attr(instance, "auth", "gcp", 
"service_account_json")
-        if sa_json then
+        if sa_json and not secret.is_secret_ref(sa_json) then
             local _, err = core.json.decode(sa_json)
             if err then
                 return false, "invalid gcp service_account_json: " .. err
diff --git a/apisix/plugins/ai-proxy.lua b/apisix/plugins/ai-proxy.lua
index dec03592b..8922c1633 100644
--- a/apisix/plugins/ai-proxy.lua
+++ b/apisix/plugins/ai-proxy.lua
@@ -15,6 +15,7 @@
 -- limitations under the License.
 --
 local core = require("apisix.core")
+local secret = require("apisix.secret")
 local schema = require("apisix.plugins.ai-proxy.schema")
 local base = require("apisix.plugins.ai-proxy.base")
 local exporter = require("apisix.plugins.prometheus.exporter")
@@ -42,7 +43,7 @@ function _M.check_schema(conf)
         return false, "ai provider: " .. conf.provider .. " is not supported."
     end
     local sa_json = core.table.try_read_attr(conf, "auth", "gcp", 
"service_account_json")
-    if sa_json then
+    if sa_json and not secret.is_secret_ref(sa_json) then
         local _, err = core.json.decode(sa_json)
         if err then
             return false, "invalid gcp service_account_json: " .. err
diff --git a/apisix/plugins/authz-keycloak.lua 
b/apisix/plugins/authz-keycloak.lua
index 7efa27aa6..7c8eae5c1 100644
--- a/apisix/plugins/authz-keycloak.lua
+++ b/apisix/plugins/authz-keycloak.lua
@@ -20,7 +20,6 @@ local sub_str   = string.sub
 local type      = type
 local ngx       = ngx
 local plugin_name = "authz-keycloak"
-local fetch_secrets    = require("apisix.secret").fetch_secrets
 
 local log = core.log
 local pairs = pairs
@@ -763,8 +762,6 @@ local function generate_token_using_password_grant(conf,ctx)
 end
 
 function _M.access(conf, ctx)
-    -- resolve secrets
-    conf = fetch_secrets(conf, true)
     local headers = core.request.headers(ctx)
     local need_grant_token = conf.password_grant_token_generation_incoming_uri 
and
         ctx.var.request_uri == 
conf.password_grant_token_generation_incoming_uri and
diff --git a/apisix/plugins/clickhouse-logger.lua 
b/apisix/plugins/clickhouse-logger.lua
index 7f8bce02c..99135f707 100644
--- a/apisix/plugins/clickhouse-logger.lua
+++ b/apisix/plugins/clickhouse-logger.lua
@@ -15,7 +15,6 @@
 -- limitations under the License.
 --
 
-local fetch_secrets   = require("apisix.secret").fetch_secrets
 local bp_manager_mod  = require("apisix.utils.batch-processor-manager")
 local log_util        = require("apisix.utils.log-util")
 local plugin          = require("apisix.plugin")
@@ -107,8 +106,6 @@ end
 
 
 local function send_http_data(conf, log_message)
-    conf = fetch_secrets(conf, true)
-
     local err_msg
     local res = true
     local selected_endpoint_addr
diff --git a/apisix/plugins/jwt-auth.lua b/apisix/plugins/jwt-auth.lua
index f56d88eba..c54772182 100644
--- a/apisix/plugins/jwt-auth.lua
+++ b/apisix/plugins/jwt-auth.lua
@@ -19,6 +19,7 @@ local consumer_mod = require("apisix.consumer")
 local new_tab = require ("table.new")
 local auth_utils = require("apisix.utils.auth")
 
+local secret   = require("apisix.secret")
 local ngx_decode_base64 = ngx.decode_base64
 local ngx      = ngx
 local sub_str  = string.sub
@@ -182,7 +183,7 @@ function _M.check_schema(conf, schema_type)
         return false, "property \"secret\" is required when using HS based 
algorithms"
     end
 
-    if conf.base64_secret then
+    if conf.base64_secret and not secret.is_secret_ref(conf.secret) then
         if ngx_decode_base64(conf.secret) == nil then
             return false, "base64_secret required but the secret is not in 
base64 format"
         end
diff --git a/apisix/plugins/limit-count.lua b/apisix/plugins/limit-count.lua
index 18beb5b78..a936f8acb 100644
--- a/apisix/plugins/limit-count.lua
+++ b/apisix/plugins/limit-count.lua
@@ -14,7 +14,6 @@
 -- See the License for the specific language governing permissions and
 -- limitations under the License.
 --
-local fetch_secrets = require("apisix.secret").fetch_secrets
 local limit_count = require("apisix.plugins.limit-count.init")
 local workflow = require("apisix.plugins.workflow")
 
@@ -34,7 +33,6 @@ end
 
 
 function _M.access(conf, ctx)
-    conf = fetch_secrets(conf, true)
     return limit_count.rate_limit(conf, ctx, plugin_name, 1)
 end
 
diff --git a/apisix/plugins/openid-connect.lua 
b/apisix/plugins/openid-connect.lua
index 4427caf06..1ebc12dec 100644
--- a/apisix/plugins/openid-connect.lua
+++ b/apisix/plugins/openid-connect.lua
@@ -16,9 +16,9 @@
 --
 
 local core              = require("apisix.core")
+local secret            = require("apisix.secret")
 local ngx_re            = require("ngx.re")
 local openidc           = require("resty.openidc")
-local fetch_secrets     = require("apisix.secret").fetch_secrets
 local jsonschema        = require('jsonschema')
 local string            = string
 local ngx               = ngx
@@ -433,7 +433,7 @@ function _M.check_schema(conf)
         return false, err
     end
 
-    if conf.claim_schema then
+    if conf.claim_schema and not secret.is_secret_ref(conf.claim_schema) then
         local ok, res = pcall(jsonschema.generate_validator, conf.claim_schema)
         if not ok then
             return false, "check claim_schema failed: " .. tostring(res)
@@ -624,8 +624,7 @@ end
 
 
 function _M.rewrite(plugin_conf, ctx)
-    local conf_clone = core.table.clone(plugin_conf)
-    local conf = fetch_secrets(conf_clone, true)
+    local conf = core.table.clone(plugin_conf)
 
     -- Previously, we multiply conf.timeout before storing it in etcd.
     -- If the timeout is too large, we should not multiply it again.
diff --git a/apisix/plugins/proxy-rewrite.lua b/apisix/plugins/proxy-rewrite.lua
index 21f44bc43..c1609c612 100644
--- a/apisix/plugins/proxy-rewrite.lua
+++ b/apisix/plugins/proxy-rewrite.lua
@@ -15,6 +15,7 @@
 -- limitations under the License.
 --
 local core        = require("apisix.core")
+local secret      = require("apisix.secret")
 local plugin_name = "proxy-rewrite"
 local pairs       = pairs
 local ipairs      = ipairs
@@ -195,11 +196,17 @@ function _M.check_schema(conf)
             return false, "The length of regex_uri should be an even number"
         end
         for i = 1, #conf.regex_uri, 2 do
-            local _, _, err = re_sub("/fake_uri", conf.regex_uri[i],
-                conf.regex_uri[i + 1], "jo")
-            if err then
-                return false, "invalid regex_uri(" .. conf.regex_uri[i] ..
-                    ", " .. conf.regex_uri[i + 1] .. "): " .. err
+            local pattern = conf.regex_uri[i]
+            local replacement = conf.regex_uri[i + 1]
+            if not secret.is_secret_ref(pattern) then
+                local test_replacement = secret.is_secret_ref(replacement)
+                                         and "" or replacement
+                local _, _, err = re_sub("/fake_uri", pattern,
+                    test_replacement, "jo")
+                if err then
+                    return false, "invalid regex_uri(" .. pattern ..
+                        ", " .. replacement .. "): " .. err
+                end
             end
         end
     end
diff --git a/apisix/plugins/redirect.lua b/apisix/plugins/redirect.lua
index c553ccd7e..da81859c3 100644
--- a/apisix/plugins/redirect.lua
+++ b/apisix/plugins/redirect.lua
@@ -15,6 +15,7 @@
 -- limitations under the License.
 --
 local core = require("apisix.core")
+local secret = require("apisix.secret")
 local plugin = require("apisix.plugin")
 local tab_insert = table.insert
 local tab_concat = table.concat
@@ -107,12 +108,18 @@ function _M.check_schema(conf)
     end
 
     if conf.regex_uri and #conf.regex_uri > 0 then
-        local _, _, err = re_sub("/fake_uri", conf.regex_uri[1],
-                                 conf.regex_uri[2], "jo")
-        if err then
-            local msg = string_format("invalid regex_uri (%s, %s), err:%s",
-                                      conf.regex_uri[1], conf.regex_uri[2], 
err)
-            return false, msg
+        local pattern = conf.regex_uri[1]
+        local replacement = conf.regex_uri[2]
+        if not secret.is_secret_ref(pattern) then
+            local test_replacement = secret.is_secret_ref(replacement)
+                                     and "" or replacement
+            local _, _, err = re_sub("/fake_uri", pattern,
+                                     test_replacement, "jo")
+            if err then
+                local msg = string_format("invalid regex_uri (%s, %s), err:%s",
+                                          pattern, replacement, err)
+                return false, msg
+            end
         end
     end
 
diff --git a/apisix/plugins/request-validation.lua 
b/apisix/plugins/request-validation.lua
index 138032f42..ae5e352ab 100644
--- a/apisix/plugins/request-validation.lua
+++ b/apisix/plugins/request-validation.lua
@@ -15,6 +15,7 @@
 -- limitations under the License.
 --
 local core          = require("apisix.core")
+local secret        = require("apisix.secret")
 local plugin_name   = "request-validation"
 local ngx           = ngx
 
@@ -48,14 +49,14 @@ function _M.check_schema(conf)
         return false, err
     end
 
-    if conf.body_schema then
+    if conf.body_schema and not secret.is_secret_ref(conf.body_schema) then
         ok, err = core.schema.valid(conf.body_schema)
         if not ok then
             return false, err
         end
     end
 
-    if conf.header_schema then
+    if conf.header_schema and not secret.is_secret_ref(conf.header_schema) then
         ok, err = core.schema.valid(conf.header_schema)
         if not ok then
             return false, err
diff --git a/apisix/plugins/response-rewrite.lua 
b/apisix/plugins/response-rewrite.lua
index adf630fe5..b86604a36 100644
--- a/apisix/plugins/response-rewrite.lua
+++ b/apisix/plugins/response-rewrite.lua
@@ -15,6 +15,7 @@
 -- limitations under the License.
 --
 local core        = require("apisix.core")
+local secret      = require("apisix.secret")
 local expr        = require("resty.expr.v1")
 local re_compile  = require("resty.core.regex").re_match_compile
 local plugin_name = "response-rewrite"
@@ -216,7 +217,7 @@ function _M.check_schema(conf)
         end
     end
 
-    if conf.body_base64 then
+    if conf.body_base64 and not secret.is_secret_ref(conf.body) then
         if not conf.body or #conf.body == 0 then
             return false, 'invalid base64 content'
         end
diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua
index 370d60a77..08e67d9cb 100644
--- a/apisix/schema_def.lua
+++ b/apisix/schema_def.lua
@@ -396,7 +396,7 @@ local certificate_scheme = {
 
 
 local private_key_schema = {
-    type = "string", minLength = 128, maxLength = 64*1024
+    type = "string", minLength = 64, maxLength = 64*1024
 }
 
 
@@ -779,12 +779,6 @@ _M.credential = {
 _M.upstream = upstream_schema
 
 
-local secret_uri_schema = {
-    type = "string",
-    pattern = "^\\$(secret|env|ENV)://"
-}
-
-
 _M.ssl = {
     type = "object",
     properties = {
@@ -804,18 +798,8 @@ _M.ssl = {
             default = "server",
             enum = {"server", "client"}
         },
-        cert = {
-            oneOf = {
-                certificate_scheme,
-                secret_uri_schema
-            }
-        },
-        key = {
-            oneOf = {
-                private_key_schema,
-                secret_uri_schema
-            }
-        },
+        cert = certificate_scheme,
+        key = private_key_schema,
         sni = {
             type = "string",
             pattern = host_def_pat,
@@ -830,21 +814,11 @@ _M.ssl = {
         },
         certs = {
             type = "array",
-            items = {
-                oneOf = {
-                    certificate_scheme,
-                    secret_uri_schema
-                }
-            }
+            items = certificate_scheme
         },
         keys = {
             type = "array",
-            items = {
-                oneOf = {
-                    private_key_schema,
-                    secret_uri_schema
-                }
-            }
+            items = private_key_schema
         },
         client = {
             type = "object",
diff --git a/apisix/secret.lua b/apisix/secret.lua
index 6c224f05e..d64851115 100644
--- a/apisix/secret.lua
+++ b/apisix/secret.lua
@@ -257,4 +257,77 @@ function _M.fetch_secrets(refs, use_cache)
     return retrieve_refs(new_refs, use_cache)
 end
 
+
+-- Used as jsonschema skip_validation hook: signature is (value, schema).
+-- Returns true to skip validation when value is a secret reference 
($secret:// or $env://).
+-- Only skips for fields whose schema accepts string values.
+function _M.is_secret_ref(value, schema)
+    if type(value) ~= "string" or byte(value, 1) ~= 36 then  -- '$'
+        return false
+    end
+    if schema and schema.type and schema.type ~= "string" then
+        return false
+    end
+    if not (string.has_prefix(value, PREFIX)
+            or string.has_prefix(upper(value), core.env.PREFIX)) then
+        return false
+    end
+
+    return true
+end
+
+
+local function _has_secret_ref(t, visited)
+    if visited[t] then
+        return false
+    end
+    visited[t] = true
+    for _, v in pairs(t) do
+        if type(v) == "string" then
+            if _M.is_secret_ref(v) then
+                return true
+            end
+        elseif type(v) == "table" then
+            if _has_secret_ref(v, visited) then
+                return true
+            end
+        end
+    end
+    return false
+end
+
+
+function _M.has_secret_ref(conf)
+    if type(conf) ~= "table" then
+        return false
+    end
+    return _has_secret_ref(conf, {})
+end
+
+
+local function _collect_secret_values(t, vals, use_cache, visited)
+    if visited[t] then
+        return
+    end
+    visited[t] = true
+    for _, v in pairs(t) do
+        if type(v) == "string" and _M.is_secret_ref(v) then
+            vals[v] = fetch(v, use_cache)
+        elseif type(v) == "table" then
+            _collect_secret_values(v, vals, use_cache, visited)
+        end
+    end
+end
+
+
+function _M.collect_secret_values(conf, use_cache)
+    local vals = {}
+    if type(conf) ~= "table" then
+        return vals
+    end
+    _collect_secret_values(conf, vals, use_cache, {})
+    return vals
+end
+
+
 return _M
diff --git a/docs/en/latest/terminology/secret.md 
b/docs/en/latest/terminology/secret.md
index 94c1b8843..27474052c 100644
--- a/docs/en/latest/terminology/secret.md
+++ b/docs/en/latest/terminology/secret.md
@@ -42,7 +42,21 @@ APISIX currently supports storing secrets in the following 
ways:
 - [AWS Secrets Manager](#use-aws-secrets-manager-to-manage-secrets)
 - [GCP Secrets Manager](#use-gcp-secrets-manager-to-manage-secrets)
 
-You can use APISIX Secret functions by specifying format variables in the 
consumer configuration of the following plugins, such as `key-auth`.
+You can use APISIX Secret functions by specifying format variables in the 
consumer configuration or the plugin configuration of any plugin, as well as in 
SSL certificate configurations.
+
+### Supported scope
+
+Secret references (`$secret://...`, `$env://...`, `$ENV://...`) can be used in 
the following contexts:
+
+- **Plugin configurations**: Any string field in any plugin configuration. 
Secret references are automatically resolved at runtime in `plugin.filter()` 
before the plugin executes.
+- **SSL certificates**: The `cert`, `key`, `certs`, and `keys` fields in SSL 
resources. Secret references are resolved during TLS handshake.
+- **Consumer auth configurations**: Any string field in consumer 
authentication plugin configurations (e.g., `key-auth`, `jwt-auth`). Secret 
references are resolved when consumer configuration is loaded.
+
+:::tip
+
+When a configuration field uses a secret reference like `$secret://...` or 
`$env://...`, schema validation constraints (such as `enum`, `pattern`, 
`minLength`, `maxLength`) on that field are automatically bypassed during 
configuration loading. The resolved value is used directly at runtime without 
re-validation against the schema — ensure your secret values are valid for the 
target field.
+
+:::
 
 :::note
 
diff --git a/docs/zh/latest/terminology/secret.md 
b/docs/zh/latest/terminology/secret.md
index 22a3f4902..fabe66f49 100644
--- a/docs/zh/latest/terminology/secret.md
+++ b/docs/zh/latest/terminology/secret.md
@@ -42,7 +42,21 @@ APISIX 目前支持通过以下方式存储密钥:
 - [AWS Secrets Manager](#使用-aws-secrets-manager-管理密钥)
 - [GCP Secrets Manager](#使用-gcp-secrets-manager-管理密钥)
 
-你可以在以下插件的 consumer 配置中通过指定格式的变量来使用 APISIX Secret 功能,比如 `key-auth` 插件。
+你可以在消费者配置或任何插件的配置中通过指定格式的变量来使用 APISIX Secret 功能,SSL 证书配置中同样支持。
+
+### 支持范围
+
+密钥引用(`$secret://...`、`$env://...`、`$ENV://...`)可以在以下场景中使用:
+
+- **插件配置**:任何插件配置中的任意字符串字段。密钥引用在 `plugin.filter()` 中自动解析,在插件执行前完成替换。
+- **SSL 证书**:SSL 资源的 `cert`、`key`、`certs` 和 `keys` 字段。密钥引用在 TLS 握手时解析。
+- **消费者认证配置**:消费者认证插件配置中的任意字符串字段(如 `key-auth`、`jwt-auth`)。密钥引用在消费者配置加载时解析。
+
+:::tip
+
+当配置字段使用了 `$secret://...` 或 `$env://...` 格式的密钥引用时,该字段上的 schema 校验约束(如 
`enum`、`pattern`、`minLength`、`maxLength`)会在配置加载时自动跳过。解析后的值在运行时直接使用,不会再次进行 
schema 校验——请确保密钥的实际值对目标字段有效。
+
+:::
 
 :::note
 
diff --git a/t/admin/ssl.t b/t/admin/ssl.t
index 36c05018f..7dac892bf 100644
--- a/t/admin/ssl.t
+++ b/t/admin/ssl.t
@@ -799,7 +799,7 @@ passed
 GET /t
 --- error_code: 400
 --- response_body
-{"error_msg":"invalid configuration: property \"certs\" validation failed: 
failed to validate item 1: value should match only one schema, but matches 
none"}
+{"error_msg":"invalid configuration: property \"certs\" validation failed: 
failed to validate item 1: string too short, expected at least 128, got 29"}
 
 
 
diff --git a/t/secret/central-secret-refs.t b/t/secret/central-secret-refs.t
new file mode 100644
index 000000000..47f99675c
--- /dev/null
+++ b/t/secret/central-secret-refs.t
@@ -0,0 +1,376 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+BEGIN {
+    $ENV{TEST_HOST} = "test.example.com";
+    $ENV{TEST_URI} = "/hello";
+    $ENV{TEST_HEADER_VALUE} = "from-env";
+}
+
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+no_shuffle();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (!defined $block->request) {
+        $block->set_value("request", "GET /t");
+    }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: proxy-rewrite resolves $env:// references automatically (central 
resolution)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/hello",
+                    "plugins": {
+                        "proxy-rewrite": {
+                            "host": "$env://TEST_HOST"
+                        }
+                    },
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                return ngx.say(body)
+            end
+            ngx.say("success")
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 2: verify host header is resolved from env variable
+--- request
+GET /hello
+--- response_headers
+!X-Test-Error
+--- response_body_like
+.*
+--- error_log
+matched route
+
+
+
+=== TEST 3: proxy-rewrite resolves $env:// in uri field with schema constraints
+proxy-rewrite.uri has minLength, maxLength, and pattern constraints.
+The $env://TEST_URI string itself doesn't match the ^/.* pattern,
+but the jsonschema skip_validation hook bypasses validation for secret ref 
values.
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/hello",
+                    "plugins": {
+                        "proxy-rewrite": {
+                            "uri": "$env://TEST_URI"
+                        }
+                    },
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                return ngx.say(body)
+            end
+            ngx.say("success")
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 4: verify uri is resolved from env variable
+--- request
+GET /hello
+--- error_code: 200
+
+
+
+=== TEST 5: schema validation skips nested secret ref fields
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/hello",
+                    "plugins": {
+                        "proxy-rewrite": {
+                            "headers": {
+                                "set": {
+                                    "X-Custom": "$env://TEST_HEADER_VALUE"
+                                }
+                            }
+                        }
+                    },
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                return ngx.say(body)
+            end
+            ngx.say("success")
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 6: fetch_secrets resolves $env:// and preserves original conf
+--- config
+    location /t {
+        content_by_lua_block {
+            local secret = require("apisix.secret")
+
+            local conf = {
+                host = "$env://TEST_HOST",
+                uri = "/test"
+            }
+            local resolved = secret.fetch_secrets(conf, true)
+            ngx.say("resolved host: ", resolved.host)
+            ngx.say("original host: ", conf.host)
+        }
+    }
+--- response_body
+resolved host: test.example.com
+original host: $env://TEST_HOST
+
+
+
+=== TEST 7: has_secret_ref detects nested references
+--- config
+    location /t {
+        content_by_lua_block {
+            local secret = require("apisix.secret")
+
+            ngx.say(secret.has_secret_ref({key = "$env://X"}))
+            ngx.say(secret.has_secret_ref({nested = {key = 
"$secret://vault/1/k"}}))
+            ngx.say(secret.has_secret_ref({key = "plain"}))
+            ngx.say(secret.has_secret_ref({nested = {key = "plain"}}))
+        }
+    }
+--- response_body
+true
+true
+false
+false
+
+
+
+=== TEST 8: conf without secret refs passes schema validation normally
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/hello",
+                    "plugins": {
+                        "proxy-rewrite": {
+                            "host": "normal.example.com"
+                        }
+                    },
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                return ngx.say(body)
+            end
+            ngx.say("success")
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 9: normal conf request succeeds
+--- request
+GET /hello
+--- error_code: 200
+
+
+
+=== TEST 10: limit-count with $env:// in redis_password passes schema 
validation
+limit-count redis policy has password field constraints.
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/hello",
+                    "plugins": {
+                        "limit-count": {
+                            "count": 10,
+                            "time_window": 60,
+                            "key_type": "var",
+                            "key": "remote_addr",
+                            "policy": "redis",
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "redis_password": "$env://TEST_HEADER_VALUE"
+                        }
+                    },
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                return ngx.say(body)
+            end
+            ngx.say("success")
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 11: limit-count redis_host as $env:// passes conditional required 
validation
+redis_host is required when policy=redis (via if/then schema).
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/hello",
+                    "plugins": {
+                        "limit-count": {
+                            "count": 10,
+                            "time_window": 60,
+                            "key_type": "var",
+                            "key": "remote_addr",
+                            "policy": "redis",
+                            "redis_host": "$env://TEST_HEADER_VALUE",
+                            "redis_port": 6379
+                        }
+                    },
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                return ngx.say(body)
+            end
+            ngx.say("success")
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 12: openid-connect with client_secret as $env:// passes schema 
validation
+openid-connect has required field client_secret that must be a string.
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "uri": "/hello",
+                    "plugins": {
+                        "openid-connect": {
+                            "client_id": "my-client",
+                            "client_secret": "$env://TEST_HEADER_VALUE",
+                            "discovery": 
"http://127.0.0.1:8090/.well-known/openid-configuration";,
+                            "bearer_only": true
+                        }
+                    },
+                    "upstream": {
+                        "type": "roundrobin",
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        }
+                    }
+                }]]
+            )
+
+            if code >= 300 then
+                ngx.status = code
+                return ngx.say(body)
+            end
+            ngx.say("success")
+        }
+    }
+--- response_body
+success


Reply via email to