July 31, 2026
Secure Storage of AI API Keys for Opencode
The rise of AI has created yet another valuable target for hackers — your provider’s API key. Malicious code can scan your home directory…

By Stanislav Nabatnikov
3 min read
The rise of AI has created yet another valuable target for hackers — your provider's API key. Malicious code can scan your home directory, find keys, and use them anywhere.
Even worse, if an AI provider doesn't guarantee they won't train on user data (like the original DeepSeek), then while helping you configure your system, it might scan files containing keys. All of that flies up to the cloud, and who knows — someday the AI might offer your keys to someone else as a placeholder.
In short, if the provider explicitly tells you "don't scatter your key around" when generating it, and doesn't store it themselves (if you don't copy the key right away, you'll never see it again and will have to create a new one), then perhaps you should heed that advice.
The Problem with Our Work Tool
But it's not that simple. My OpenCode CLI on Linux Kubuntu, when adding a provider via the menu, casually writes the key into two files:
.local/share/opencode/auth.json.local/share/opencode/account.json
What to do? Store keys in the built-in vault and hand them out on request. No files on disk!
Phase 1: Store Keys in the Built-in KWallet
# 1. Get a HANDLE (any app name works, e.g. "ai-loader")
HANDLE=$(qdbus6 org.kde.kwalletd6 /modules/kwalletd6 org.kde.KWallet.open yourwallet 0 ai-loader)
# 2. Create the secret_api folder (run once)
qdbus6 org.kde.kwalletd6 /modules/kwalletd6 org.kde.KWallet.createFolder "$HANDLE" secret_api ai-loader
# 3. Write the actual mistral key
qdbus6 org.kde.kwalletd6 /modules/kwalletd6 org.kde.KWallet.writePassword "$HANDLE" secret_api mistral "YOUR_ACTUAL_MISTRAL_KEY" ai-loader# 1. Get a HANDLE (any app name works, e.g. "ai-loader")
HANDLE=$(qdbus6 org.kde.kwalletd6 /modules/kwalletd6 org.kde.KWallet.open yourwallet 0 ai-loader)
# 2. Create the secret_api folder (run once)
qdbus6 org.kde.kwalletd6 /modules/kwalletd6 org.kde.KWallet.createFolder "$HANDLE" secret_api ai-loader
# 3. Write the actual mistral key
qdbus6 org.kde.kwalletd6 /modules/kwalletd6 org.kde.KWallet.writePassword "$HANDLE" secret_api mistral "YOUR_ACTUAL_MISTRAL_KEY" ai-loader
Phase 2: Retrieve the Key
So how do we pass the key to the agent?
Create a script in a directory that's in your PATH — by default, ~/.local/bin.
cat > ~/.local/bin/load-secrets << 'EOF'
#!/usr/bin/env bash
# syntax: load-secrets command args...
export MISTRAL_API_KEY="$(kwallet-query -f secret_api -r 'mistral' yourwallet 2>/dev/null || echo '')"
if [ -z "$MISTRAL_API_KEY" ]; then
echo "Warning: KWallet may be locked or key not found" >&2
fi
# this is a workaround needed because Zen wants this specific env var for autocomplete,
# while Mistral long ago merged it into their main API key
export CODESTRAL_API_KEY="$MISTRAL_API_KEY"
# Run the given program with its arguments.
# exec replaces the current shell process with the program's process.
# When the program exits, this subshell (and all keys in its memory) simply vanishes.
exec "$@"
EOF
# don't forget to make it executable
chmod +x ~/.local/bin/load-secrets
# verify kwallet-query is available (if not, install the kwalletcli package)
which kwallet-querycat > ~/.local/bin/load-secrets << 'EOF'
#!/usr/bin/env bash
# syntax: load-secrets command args...
export MISTRAL_API_KEY="$(kwallet-query -f secret_api -r 'mistral' yourwallet 2>/dev/null || echo '')"
if [ -z "$MISTRAL_API_KEY" ]; then
echo "Warning: KWallet may be locked or key not found" >&2
fi
# this is a workaround needed because Zen wants this specific env var for autocomplete,
# while Mistral long ago merged it into their main API key
export CODESTRAL_API_KEY="$MISTRAL_API_KEY"
# Run the given program with its arguments.
# exec replaces the current shell process with the program's process.
# When the program exits, this subshell (and all keys in its memory) simply vanishes.
exec "$@"
EOF
# don't forget to make it executable
chmod +x ~/.local/bin/load-secrets
# verify kwallet-query is available (if not, install the kwalletcli package)
which kwallet-queryIn .bashrc, insert the command alias anywhere. Now, when you type opencode in the terminal, the load-secrets script will run first:
alias opencode="load-secrets opencode"alias opencode="load-secrets opencode"Note: if your terminal doesn't read
.bashrc(e.g. login shell), also add the alias to.bash_profileor.profile. Additionally, this method only works for terminal launches — if OpenCode is started via a.desktopfile or GUI launcher, the KWallet environment variables won't be loaded.
Phase 3: Remove Exposed Keys
OpenCode searches for keys in the environment if it doesn't find them in its own files. So we can leave just a minimal, harmless structure that no longer affects anything.
⚠️ WARNING! This command will overwrite the contents of these files, so only run it after saving keys to KWallet.
echo "{}" > ~/.local/share/opencode/auth.json
echo '{"version": 2, "accounts": {}, "active": {}}' > ~/.local/share/opencode/account.jsonecho "{}" > ~/.local/share/opencode/auth.json
echo '{"version": 2, "accounts": {}, "active": {}}' > ~/.local/share/opencode/account.jsonPhase 4: Adding a New Provider
To add a new provider, simply add its <provider_name>_API_KEY variable to our key loader. OpenCode will pick it up automatically.
What Risks We Eliminated
Moving sensitive data from disk to a vault doesn't close every threat that could lead to theft, but it's better than nothing. Let's walk through them.
Automated Scraping
- Action: a simple bot scans GitHub or your ~ folder for keys in
.envor.jsonfiles. - Protection: 100%. There is no key on disk.
Malicious IDE Extension
- Action: attempts to read private config files or the memory of neighboring processes.
- Protection: 100%. No file exists, and memory access via
ptraceis blocked by the kernel.
AI Agent File Scanning
- Action: an agent, while helping you configure your system or itself, scans files containing keys. Later, those keys could "surface" in someone else's output.
- Protection: 100%. There is no file on disk.
Targeted Breach (RCE)
- Action: an attacker breached the network perimeter and gained access to your Bash environment.
- Protection: none. The attacker has your user's privileges on the system.
Running an Executable from the User
- Action: you yourself ran a script (
the_best_skill.sh) that invokeskwallet-queryor reads/proc/$PID/environ. - Protection: none. The code runs in your session and has permission to read your KWallet.
Conclusion
The primary defense of an API key in local development without auto-confirmation is still to avoid keeping more than you'd be sorry to lose, and to set spending limits on the LLM provider's side.
But you can remove some of the risk with very little effort.