Summary
Plugin userConfig options are not available to ${VAR} expansion in http hook headers. A header like Authorization: Bearer ${CLAUDE_PLUGIN_OPTION_TOKEN} (with allowedEnvVars: ["CLAUDE_PLUGIN_OPTION_TOKEN"]) expands to an empty string even when the option is set, so the request is sent as Authorization: Bearer. Expansion draws only from the process environment — plugin option values are never injected for http hooks.
The docs say plugin option values are "exported to hook processes as CLAUDE_PLUGIN_OPTION_<KEY> environment variables" (plugins reference). That holds for command hooks (subprocesses), but http hooks are POSTed by Claude Code itself, and there the variables don't exist. This makes it impossible for a plugin to declare an authenticated http hook — which is otherwise the perfect shape for a "no binary on this machine" integration.
Environment
- Claude Code 2.1.220, Linux (also reproduced on 2.1.2xx earlier builds)
- Plugin installed with
claude plugin install name@marketplace --config 'token=<value>'; value confirmed stored under pluginSecrets in ~/.claude/.credentials.json (the option is sensitive: true)
Minimal reproduction
.claude-plugin/plugin.json:
{
"name": "authtest",
"version": "1.0.0",
"description": "repro",
"userConfig": {
"token": { "type": "string", "sensitive": true, "title": "t", "description": "t", "default": "" }
}
}
hooks/hooks.json:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "http",
"url": "http://127.0.0.1:8474/v1/hook/UserPromptSubmit",
"headers": { "Authorization": "Bearer ${CLAUDE_PLUGIN_OPTION_TOKEN}" },
"allowedEnvVars": ["CLAUDE_PLUGIN_OPTION_TOKEN"],
"timeout": 3
}
]
}
]
}
}
Capture server: any local HTTP server on 8474 that logs the Authorization header.
claude plugin install authtest@testmkt --config 'token=capture-test-secret-42'
claude -p "reply with just: ok"
Observed
The capture server receives:
The configured option value is never sent. Control experiment — putting the variable in the actual process environment DOES expand:
CLAUDE_PLUGIN_OPTION_TOKEN=from-process-env claude -p "reply with just: ok"
# capture server receives: Authorization: Bearer from-process-env
So allowedEnvVars + header expansion work as documented for real environment variables; the missing piece is that plugin userConfig values are not part of that namespace for http hooks.
Expected
One of:
CLAUDE_PLUGIN_OPTION_<KEY> (and/or ${user_config.KEY}) is resolvable in a plugin-declared http hook's headers, consistent with the plugins-reference wording that option values are exported to hook processes; or
- The hooks/plugins docs explicitly state that http hooks cannot access plugin options, so plugin authors don't ship auth designs that silently send empty credentials.
Option 1 is strongly preferred: header interpolation is not shell evaluation, so the injection concern that motivated rejecting ${user_config.*} in shell-form fields (see #76567 and the v2.1.207 change) doesn't apply — the value lands in an HTTP header byte-for-byte.
Real-world impact
Our plugin (localvoxtral-remote, localvoxtral) publishes session context from an SSH remote host to a loopback listener through a RemoteForward tunnel, authenticated by a per-host bearer token issued at enrollment. Declarative http hooks meant nothing to install on the remote host beyond the plugin itself. Every hook fires with an empty bearer and is rejected 401; users see UserPromptSubmit hook error: HTTP 401 on every prompt. We're converting to a command hook with curl as a workaround, which reintroduces a runtime dependency the http hook type exists to avoid.
Summary
Plugin
userConfigoptions are not available to${VAR}expansion in http hook headers. A header likeAuthorization: Bearer ${CLAUDE_PLUGIN_OPTION_TOKEN}(withallowedEnvVars: ["CLAUDE_PLUGIN_OPTION_TOKEN"]) expands to an empty string even when the option is set, so the request is sent asAuthorization: Bearer. Expansion draws only from the process environment — plugin option values are never injected for http hooks.The docs say plugin option values are "exported to hook processes as
CLAUDE_PLUGIN_OPTION_<KEY>environment variables" (plugins reference). That holds forcommandhooks (subprocesses), but http hooks are POSTed by Claude Code itself, and there the variables don't exist. This makes it impossible for a plugin to declare an authenticated http hook — which is otherwise the perfect shape for a "no binary on this machine" integration.Environment
claude plugin install name@marketplace --config 'token=<value>'; value confirmed stored underpluginSecretsin~/.claude/.credentials.json(the option issensitive: true)Minimal reproduction
.claude-plugin/plugin.json:{ "name": "authtest", "version": "1.0.0", "description": "repro", "userConfig": { "token": { "type": "string", "sensitive": true, "title": "t", "description": "t", "default": "" } } }hooks/hooks.json:{ "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "http", "url": "http://127.0.0.1:8474/v1/hook/UserPromptSubmit", "headers": { "Authorization": "Bearer ${CLAUDE_PLUGIN_OPTION_TOKEN}" }, "allowedEnvVars": ["CLAUDE_PLUGIN_OPTION_TOKEN"], "timeout": 3 } ] } ] } }Capture server: any local HTTP server on 8474 that logs the
Authorizationheader.Observed
The capture server receives:
The configured option value is never sent. Control experiment — putting the variable in the actual process environment DOES expand:
So
allowedEnvVars+ header expansion work as documented for real environment variables; the missing piece is that plugin userConfig values are not part of that namespace for http hooks.Expected
One of:
CLAUDE_PLUGIN_OPTION_<KEY>(and/or${user_config.KEY}) is resolvable in a plugin-declared http hook'sheaders, consistent with the plugins-reference wording that option values are exported to hook processes; orOption 1 is strongly preferred: header interpolation is not shell evaluation, so the injection concern that motivated rejecting
${user_config.*}in shell-form fields (see #76567 and the v2.1.207 change) doesn't apply — the value lands in an HTTP header byte-for-byte.Real-world impact
Our plugin (localvoxtral-remote, localvoxtral) publishes session context from an SSH remote host to a loopback listener through a
RemoteForwardtunnel, authenticated by a per-host bearer token issued at enrollment. Declarative http hooks meant nothing to install on the remote host beyond the plugin itself. Every hook fires with an empty bearer and is rejected 401; users seeUserPromptSubmit hook error: HTTP 401on every prompt. We're converting to acommandhook with curl as a workaround, which reintroduces a runtime dependency the http hook type exists to avoid.