BUG REPORT: Config Secrets Exposed in JSON After Update/Doctor
Severity: CRITICAL 🔴 (Security)
OpenClaw Version: 2026.2.3
Affected Commands: openclaw update, openclaw doctor, openclaw configure
Summary
When OpenClaw runs config write operations (during update, doctor, or configure commands), environment variable references in the config file are resolved and replaced with actual credentials, permanently storing secrets in plain text JSON.
Expected behavior: Environment variable syntax ${VAR_NAME} should be preserved in the config file.
Actual behavior: After running openclaw update, credentials are baked into openclaw.json:
{
"tools": {
"web": {
"search": {
"apiKey": "bsc_1234567890abcdef_example" // ❌ Should be "${BRAVE_SEARCH_API_KEY}"
}
}
},
"channels": {
"telegram": {
"botToken": "1234567890:ABCDefGhIJKlmNoPqRsTuVwXyZ_example" // ❌ Should be "${TELEGRAM_BOT_TOKEN}"
}
}
}
Root Cause
In src/config/config-io.ts (compiled to dist/config-*.js):
// 1. Config is loaded and resolved
const resolved = resolveConfigEnvVars(obj, env); // ${VAR} → actual values
// 2. Config is validated with resolved values
const validated = validateConfigObjectWithPlugins(resolved);
// 3. Config is written back to disk - but env vars are already gone!
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2);
await fs.promises.writeFile(configPath, json, ...);
The writeConfigFile() function does not preserve or restore the original ${VAR} syntax after resolution.
Impact
Security:
- Credentials leak into config files that may be:
- Backed up
- Version controlled (git)
- Exposed via config management systems
- Copied between machines
- Violations of secret management best practices
Operational:
- Users must manually restore
${VAR} references after every update
- Config files become inconsistent between environments
- Rotation of secrets becomes risky
Reproduction Steps
- Create
openclaw.json with env var reference:
{
"tools": {
"web": {
"search": {
"apiKey": "${BRAVE_SEARCH_API_KEY}"
}
}
}
}
- Set environment variable:
export BRAVE_SEARCH_API_KEY="sk-abc123..."
- Run:
openclaw doctor
# or
openclaw update
- Check result:
grep "apiKey" openclaw.json
# Output: "apiKey": "sk-abc123..." ❌ Credential exposed!
Solution Options
Option A: Preserve Env Var Syntax (Recommended)
Modify writeConfigFile() to track which fields used ${VAR} syntax during load, then restore that syntax on write:
interface ConfigMetadata {
envVarFields: Map<string, string>; // path → original ${VAR} syntax
}
function writeConfigFile(cfg: Config, metadata: ConfigMetadata) {
const json = JSON.stringify(cfg);
// Restore ${VAR} syntax for fields that had it
for (const [path, varRef] of metadata.envVarFields) {
json = restoreEnvVarSyntax(json, path, varRef);
}
await fs.promises.writeFile(configPath, json);
}
Option B: Skip Config Write During Doctor/Update
Only write config when explicitly requested by user, not during routine operations:
async function doctor(opts: { writeConfig?: boolean } = {}) {
// ... diagnostics ...
if (opts.writeConfig) {
await writeConfigFile(cfg); // Only if user explicitly asks
}
}
Option C: Redact Credentials on Write
Document best practice: "Never commit openclaw.json to git; use .env or systemd env vars instead"
- Add
.gitignore template
- Provide migration guide
Workaround (Current)
Until this is fixed, store all secrets in systemd environment variables:
~/.config/systemd/user/openclaw-gateway.service:
[Service]
Environment="BRAVE_SEARCH_API_KEY=your-key-here"
Environment="TELEGRAM_BOT_TOKEN=your-token-here"
Environment="OPENROUTER_API_KEY=your-key-here"
~/.openclaw/openclaw.json:
{
"tools": {
"web": {
"search": {
"apiKey": "${BRAVE_SEARCH_API_KEY}"
}
}
},
"channels": {
"telegram": {
"botToken": "${TELEGRAM_BOT_TOKEN}"
}
}
}
Then reload:
systemctl --user daemon-reload
systemctl --user restart openclaw-gateway
Additional Notes
- Config resolution happens in
resolveConfigEnvVars() and substituteAny()
- These functions correctly handle env var expansion at load time
- Problem is that write path doesn't have reverse mapping
- Affects all fields with
apiKey, botToken, token that use ${VAR} syntax
- Multi-environment setups (dev/prod) are particularly affected
Files to Review
src/config/config-io.ts — writeConfigFile() function
src/config/env-vars.ts — resolveConfigEnvVars(), substituteAny()
src/config/index.ts — Config loading pipeline
Reporter: Cosmo (via security audit)
Date: 2026-02-05
Status: OPEN
BUG REPORT: Config Secrets Exposed in JSON After Update/Doctor
Severity: CRITICAL 🔴 (Security)
OpenClaw Version: 2026.2.3
Affected Commands:
openclaw update,openclaw doctor,openclaw configureSummary
When OpenClaw runs config write operations (during
update,doctor, orconfigurecommands), environment variable references in the config file are resolved and replaced with actual credentials, permanently storing secrets in plain text JSON.Expected behavior: Environment variable syntax
${VAR_NAME}should be preserved in the config file.Actual behavior: After running
openclaw update, credentials are baked intoopenclaw.json:{ "tools": { "web": { "search": { "apiKey": "bsc_1234567890abcdef_example" // ❌ Should be "${BRAVE_SEARCH_API_KEY}" } } }, "channels": { "telegram": { "botToken": "1234567890:ABCDefGhIJKlmNoPqRsTuVwXyZ_example" // ❌ Should be "${TELEGRAM_BOT_TOKEN}" } } }Root Cause
In
src/config/config-io.ts(compiled todist/config-*.js):The
writeConfigFile()function does not preserve or restore the original${VAR}syntax after resolution.Impact
Security:
Operational:
${VAR}references after every updateReproduction Steps
openclaw.jsonwith env var reference:{ "tools": { "web": { "search": { "apiKey": "${BRAVE_SEARCH_API_KEY}" } } } }openclaw doctor # or openclaw updateSolution Options
Option A: Preserve Env Var Syntax (Recommended)
Modify
writeConfigFile()to track which fields used${VAR}syntax during load, then restore that syntax on write:Option B: Skip Config Write During Doctor/Update
Only write config when explicitly requested by user, not during routine operations:
Option C: Redact Credentials on Write
Document best practice: "Never commit
openclaw.jsonto git; use.envor systemd env vars instead".gitignoretemplateWorkaround (Current)
Until this is fixed, store all secrets in systemd environment variables:
~/.config/systemd/user/openclaw-gateway.service:~/.openclaw/openclaw.json:{ "tools": { "web": { "search": { "apiKey": "${BRAVE_SEARCH_API_KEY}" } } }, "channels": { "telegram": { "botToken": "${TELEGRAM_BOT_TOKEN}" } } }Then reload:
Additional Notes
resolveConfigEnvVars()andsubstituteAny()apiKey,botToken,tokenthat use${VAR}syntaxFiles to Review
src/config/config-io.ts—writeConfigFile()functionsrc/config/env-vars.ts—resolveConfigEnvVars(),substituteAny()src/config/index.ts— Config loading pipelineReporter: Cosmo (via security audit)
Date: 2026-02-05
Status: OPEN