
AI Agents Still Need Passwords: Three Approaches to Credential Security
I use Claude Code with browser automation and MCP tools to manage infrastructure, and my agent constantly needs plain usernames and passwords. PostgreSQL authenticates with a password, not a token; SMTP requires a username and password; Jenkins accepts Basic Auth; Docker needs credentials passed directly to log in to private registries.
The industry guidance says to use OAuth and scoped tokens. FusionAuth's agent auth guide covers how to handle this with OAuth and JSON Web Token delegation chains. That's the right approach when every service supports it, but many don't.
So I tested three ways to give an agent a password:
- Plaintext environment variables: the baseline most people use today. Credentials are exposed both on disk and at runtime. Fine for throwaway staging credentials.
- Bitwarden MCP: credentials are encrypted at rest in a vault, but the agent still sees them in plaintext when it retrieves them. But it is free to run.
- 1Password CLI with secret references: the agent orchestrates workflows using
op://references and never sees the resolved credentials. However, it costs $3.99/month.
None of these approaches fully solve browser login. If the agent needs to fill a login form, it has to pass the actual password to the form field regardless. For CLI tools, API calls, and subprocess-based workflows, the three approaches offer different levels of credential protection.
Approach 1: Plaintext Environment Variables
Claude Code lets you configure environment variables that are available to the agent in every session. Add them to ~/.claude/settings.json under the env key:
{
"env": {
"SERVICE_EMAIL": "me@example.com",
"SERVICE_PASSWORD": "hunter2"
}
}
The downside is that your password sits in a JSON file on disk in plaintext.
The settings.json file isn't the only place the credential ends up. The password appears in the agent's conversation context every time it reads the variable, could show up in logs, and could be accidentally synced to a git repo.
Claude Code's permission model protects against unauthorized actions, not credential exposure. The IDEsaster research found over 30 vulnerabilities across major AI coding tools. One (CVE-2025-59536) allowed a cloned repo to exfiltrate env var credentials via crafted MCP server configs. It's been patched, but the point stands: if a credential is in the agent's environment in plaintext, it's only as safe as the agent's own security boundary.
For a throwaway account on a staging server, this is probably fine. For anything more sensitive, it's worth looking at the other options.
Approach 2: Bitwarden MCP
Bitwarden MCP solves the at-rest problem: credentials are encrypted in a vault instead of sitting in a JSON file on disk. You can manage, rotate, and revoke them in one place, and there's no risk of accidentally committing passwords to a git repo. It's free.
The trade-off is that when the agent retrieves a credential, it sees the full password in plaintext. The Bitwarden MCP server is a vault access tool, not a credential isolation tool. The agent also has access to your entire vault, not just the credential it needs. A compromised or manipulated agent could enumerate all stored credentials. To limit this exposure, use a dedicated Bitwarden account containing only the credentials your agent needs.
If you need encrypted storage for free and accept that the agent handles the raw secret at runtime, this is the right approach. If you want the agent to never see the password at all, skip ahead to Approach 3.
Installing the Bitwarden CLI and MCP Server
Install the Bitwarden CLI globally:
npm install -g @bitwarden/cli
Log in to your vault:
bw login
Add credentials through the Bitwarden web vault, desktop app, or CLI. The agent searches for them by name later, so use something recognizable.
The Bootstrap Problem: Who Authenticates the Agent?
Before the MCP server can read your vault, it needs an unlocked session. The bw unlock command returns a session token, but it requires your master password and the token expires between sessions. Every time you restart Claude Code, you'd need to manually run bw unlock and paste the token somewhere the MCP server can find it.
The solution I landed on uses macOS Keychain as the root of trust. Store your Bitwarden master password in Keychain once:
security add-generic-password -s "bitwarden-cli" -a "bw-master" -w
Then create a wrapper script at ~/.local/bin/bw-mcp-server.sh that retrieves the master password, unlocks the vault, and starts the MCP server automatically:
#!/bin/bash
BW_MASTER=$(security find-generic-password -s "bitwarden-cli" -a "bw-master" -w 2>/dev/null)
if [ -z "$BW_MASTER" ]; then
echo "Failed to retrieve Bitwarden master password from Keychain" >&2
exit 1
fi
export BW_MASTER
export BW_SESSION=$(bw unlock --passwordenv BW_MASTER --raw 2>/dev/null)
unset BW_MASTER
if [ -z "$BW_SESSION" ]; then
echo "Failed to unlock Bitwarden vault" >&2
exit 1
fi
exec npx -y @bitwarden/mcp-server
Make it executable:
chmod +x ~/.local/bin/bw-mcp-server.sh
At the time of writing, the wrapper script above doesn't work as-is due to a bug in the MCP server package. See the workaround at the end of this article.
The Keychain is protected by your macOS login and, on Apple Silicon, the Secure Enclave. This is the same trust boundary used for SSH keys and browser-saved passwords. It's not perfect, but it means zero manual steps between sessions. Similar tools exist for Linux (secret-tool, pass) and Windows (Credential Manager).
Connecting Bitwarden to Claude Code via MCP
Add the Bitwarden MCP server to your Claude Code config:
claude mcp add -s user bitwarden -- ~/.local/bin/bw-mcp-server.sh
Run /mcp to reconnect and the Bitwarden tools should appear.
Retrieving Credentials and Logging In
I asked Claude Code to log in to a web app and it pulled the credential from the vault, navigated to the login page, and completed the form without any manual steps.
This is an improvement over Approach 1, but the agent still handles the raw secret. If you want the agent to never see the password at all, you need a different architecture entirely.
Approach 3: 1Password CLI With Secret References
The 1Password CLI tool op takes a different approach from Bitwarden MCP. Instead of the agent retrieving credentials from a vault, you store secret references (op://vault/item/field) in your config files. The op run command resolves these references at runtime, injecting the real values as environment variables into subprocesses. The agent orchestrates the work, but the raw credentials never appear in its conversation context.
This costs $3.99/month (or a 14-day free trial), but it's the only approach I tested where the agent could use credentials without seeing them.
Installing the 1Password CLI
Install the 1Password CLI with Homebrew and verify the installation:
brew install 1password-cli
op --version
Connecting the CLI to the 1Password Desktop App
The recommended way to keep op authenticated is through the 1Password desktop app. If you don't have it installed:
brew install --cask 1password
Open the app and sign in to your account. Then enable the CLI integration:
- Go to Settings > Security and enable Touch ID.
- Go to Settings > Developer and enable Integrate with 1Password CLI.
With this enabled, op authenticates through the desktop app using Touch ID. No manual tokens or session management needed. This solves the same bootstrap problem from the Bitwarden approach, without a wrapper script. The trade-off is that you get a Touch ID prompt every time a secret is resolved, which can get tedious if the agent runs several op run commands in a session.
Test that it works:
op vault list
You should see your vaults listed. If you get a sign-in error, check that the developer integration setting is enabled.
Using Secret References Instead of Plaintext
Here's where this approach diverges from Bitwarden. Instead of having the agent retrieve the password, create a .env file containing op:// references:
EMAIL="op://Personal/My Service/username"
PASSWORD="op://Personal/My Service/password"
To run a command with the real values injected:
op run --env-file=.env -- sh -c 'curl -X POST https://api.example.com/auth/login \
-H "Content-Type: application/json" \
-d "{\"email\": \"$EMAIL\", \"password\": \"$PASSWORD\"}"'
The op run command resolves the op:// references and decrypts the secrets in memory. It sets them as environment variables for the subprocess and clears them when the process exits.
What the Agent Sees (and Doesn't See)
By default, op run masks any resolved secret that appears in stdout. If a subprocess echoes the password, the agent sees this:
password is: <concealed by 1Password>
Not the actual value. This is automatic and requires no configuration.
The agent constructs the op run command with op:// references, and the 1Password CLI handles the rest. The agent sees the references (op://Personal/My Service/password) but never the resolved values.
Compared to Bitwarden MCP, you gain:
- The raw password never appears in the agent's conversation context
- Config files contain references, not secrets, and are safe to commit
- Output masking prevents secrets from leaking through stdout
- Touch ID integration means no wrapper scripts or session token management
- Centralized credential management with sync across devices
The subprocess itself still has the secret in its environment variables at runtime, but the agent's conversation context stays clean.
Browser Login: The Unsolved Problem
The op run pattern works well for CLI tools and API calls, but it doesn't help with browser automation. If the agent needs to fill a login form, it has to pass the actual value to the form field. There's no way to pipe an environment variable into a browser input without the agent seeing it. For browser login, all three approaches end up in the same place.
I also tried getting the 1Password browser extension to handle autofill while Claude-in-Chrome handled navigation. In theory, the agent navigates to the login page and the 1Password extension fills the form. In practice, the two extensions conflict: with 1Password installed, Claude-in-Chrome's click, screenshot, and JavaScript operations fail with Cannot access a chrome-extension:// URL of different extension errors.
1Password is building Agentic Autofill to solve this, but it's not available for local Chrome yet.
For now, browser login remains the one scenario where no approach keeps the password out of the agent's context. op run with secret references is the most secure option I tested for everything else.
Other Credential Management Tools Worth Watching
This space is moving fast. Several other projects take different approaches:
- NanoClaw with OneCLI Agent Vault: proxy-based approach where HTTPS traffic is routed through a gateway that injects credentials at the network level. Open source, but requires adopting their full agent framework.
- Kernel Managed Auth: cloud browser route where a separate verified agent handles login and yours receives an authenticated session. Designed for SaaS builders, not local agent workflows.
- Multifactor: provides its own AI agent that accesses accounts through an isolated cloud browser. You can't use your own agent or tooling.
- Passwd MCP: the agent sees credential metadata but passwords are redacted to
••••••••. Uses a CLI to inject secrets into subprocesses. Requires Google Workspace.
Password managers are becoming infrastructure for AI agents. The tools aren't fully there yet, but the gap between "credentials in a .env file" and "credentials the agent never sees" is closing fast.
Bitwarden MCP Server Bug
The @bitwarden/mcp-server package (v2026.2.0) silently fails to start when run via npx. The cause is in the server's entry point: it checks process.argv[1].endsWith('index.js'), which fails when Node.js receives the npx symlink path instead of the resolved target.
To work around this, replace the last line of the wrapper script (exec npx -y @bitwarden/mcp-server) with:
# Resolve the npx symlink to the real index.js path
BW_MCP_BIN=$(find ~/.npm/_npx -name "mcp-server-bitwarden" \
-path "*/node_modules/.bin/*" 2>/dev/null | head -1)
BW_MCP_PATH=$(readlink -f "$BW_MCP_BIN" 2>/dev/null || \
python3 -c "import os; print(os.path.realpath('$BW_MCP_BIN'))")
exec node "$BW_MCP_PATH"
This may be fixed in a future version of the package.