Storing API tokens in the macOS Keychain draft

Obviously you shouldn’t put API tokens in plaintext config files. They end up in version control, shell history, and any process you launch inherits them. Instead, store them in a secure credential store and load them on demand in the shell that needs them. macOS already ships one: the Keychain.

Storing an API token in the macOS Keychain

Storing the token is a one-liner:

Shell
security add-generic-password -U -s bitbucket-api-token -a "$USER" -w
  • -s bitbucket-api-token — the item’s name, the handle later lookups use to find it
  • -a "$USER" — the account it belongs to (required, but mostly metadata)
  • -U — overwrite if the item already exists, so re-running the command rotates the token
  • -w — bare, with no value after it: security prompts for the secret — hidden input, typed twice — so the token never touches the command line or ~/.zsh_history. The man page even recommends this form.

Reading it back is security find-generic-password -w -s bitbucket-api-token -a "$USER". No “allow access?” dialog appears, because the Keychain automatically trusts the app that created an item — and security is both creator and reader here.

macOS Keychain

Letting a coding agent read the token

A side effect of this pattern showed up when Claude Code needed my Atlassian token to open a pull request on Bitbucket. The agent has no credentials of its own, and pasting a token into a chat is a habit worth breaking — not because a lookup keeps the value out of the transcript (the command’s output lands there just the same), but because nothing sits in plaintext at rest, and the keychain lookup composes neatly with the agent’s permission system: allow exactly one command, and nothing else.

JSON
{
    "permissions": {
        "allow": [
            "Bash(security find-generic-password -w -s bitbucket-api-token)"
        ]
    }
}

That’s an exact-match rule in the project’s .claude/settings.local.json — no wildcard, so a lookup of any other keychain entry still gets blocked. The agent fetches the token on demand, and the config file holds no secret — just a pointer to where it lives.

Two guardrails I was happy to see hold up in practice: Claude Code’s permission classifier refuses keychain reads by default, so the token was unreachable until I granted the rule. And when the agent tried to add that allow rule to the settings file itself, the classifier blocked that too — self-escalation is off the table. Granting access stays a human step, which is exactly where it belongs.

The MCP server that consumes it

The Bitbucket work itself goes through mcp-server-atlassian-bitbucket, an MCP server that gives the agent tools over the Bitbucket REST API — I use it to create pull requests, read and answer PR comments, and work with snippets. It authenticates with two environment variables, ATLASSIAN_USER_EMAIL and ATLASSIAN_API_TOKEN, which is where a token usually ends up pasted into an MCP config in plaintext.

The obvious move is to inline the lookup in the server’s command:

JSON
"command": "zsh",
"args": [
    "-c",
    "ATLASSIAN_API_TOKEN=\"$(security find-generic-password -w -s bitbucket-api-token)\" exec npx -y @aashari/mcp-server-atlassian-bitbucket"
]

That reads well and then fails on any machine using nvm. zsh -c is not an interactive shell, so it sources .zshenv and nothing else — never .zshrc, which is where nvm’s shell function normally lives. No nvm means no node on PATH, which means no npx, and the server dies before it ever gets to use the token it just read. Nesting quotes inside JSON inside a shell string is its own small misery on top.

A wrapper script is a better home for both problems — ~/.local/bin/bitbucket-mcp:

Shell
#!/bin/sh
set -e

# MCP servers are spawned by the editor, not by an interactive shell, so nvm
# has never run and node is not on PATH. Resolve it here.
if ! command -v npx >/dev/null 2>&1; then
    default="$(cat "$HOME/.nvm/alias/default" 2>/dev/null || true)"
    bin="$HOME/.nvm/versions/node/v${default#v}/bin"
    if [ ! -x "$bin/npx" ]; then
        # Alias missing or indirect (lts/*); fall back to the newest install.
        bin="$(/bin/ls -d "$HOME"/.nvm/versions/node/*/bin | sort -V | tail -1)"
    fi
    PATH="$bin:$PATH"
    export PATH
fi

ATLASSIAN_USER_EMAIL='me@example.com'
ATLASSIAN_API_TOKEN="$(security find-generic-password -w -s bitbucket-api-token -a "$USER")"
BITBUCKET_DEFAULT_WORKSPACE='my-workspace'
export ATLASSIAN_USER_EMAIL ATLASSIAN_API_TOKEN BITBUCKET_DEFAULT_WORKSPACE

exec npx -y @aashari/mcp-server-atlassian-bitbucket "$@"

The registration then points at the script, and carries no secret and no environment at all:

JSON
{
    "mcpServers": {
        "bitbucket": {
            "type": "stdio",
            "command": "/Users/me/.local/bin/bitbucket-mcp"
        }
    }
}

Where that block lives decides how far it reaches. In a project’s .mcp.json it exists for that one repo; at the top level of ~/.claude.json it loads in every project — which is what I want, since the Bitbucket repos I work in are scattered across several directories.

A detail worth spelling out: this path needs no permission rule at all. The allow rule further up is for when the agent itself runs the lookup as a shell command. Here the keychain read happens inside the wrapper, in the server’s own process at startup, so Claude Code never sees it as a tool call. Handy way to sanity-check the wrapper, incidentally — run it under an empty environment and it still has to find both node and the token:

Shell
env -i HOME="$HOME" USER="$USER" PATH=/usr/bin:/bin ~/.local/bin/bitbucket-mcp

The scopes will bite you

Scoped API tokens grant nothing by default. You pick the scopes when you create the token, and the set is easy to get wrong — mine had the pull request and snippet scopes but not read:repository:bitbucket, so every repository call came back 403 with a pleasingly explicit body:

JSON
{
    "message": "Your credentials lack one or more required privilege scopes.",
    "detail": {
        "required": ["read:repository:bitbucket"],
        "granted": ["read:pullrequest:bitbucket", "write:pullrequest:bitbucket"]
    }
}

Which is a reminder that “auth works” and “the token is useful” are two separate checks. A 401 means the keychain plumbing is broken; a 403 like this one means the plumbing is fine and the token is simply too narrow. Scopes are fixed at creation, so widening one means minting a new token — and that is the one-liner from the top of this post again, -U overwriting the old entry in place while every config file that points at it stays untouched.

That is the whole appeal of the pattern. The secret has exactly one home, and everything that needs it — a shell, an agent, an MCP server — holds a pointer instead of a copy.

Did you enjoy this post?