Plugin userConfig options are not expanded in http hook headers (${CLAUDE_PLUGIN_OPTION_*} resolves empty)
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 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 Authorization header.
claude plugin install authtest@testmkt --config 'token=capture-test-secret-42'
claude -p "reply with just: ok"
Observed
The capture server receives:
Authorization: Bearer
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'sheaders, 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.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗