keeping secrets in the macos keychain

Plenty of tools these days want an API token or some other secret in an environment variable. The path of least resistance is to echo 'export SOME_SECRET="a_secret"' >> ~/.zshrc and move on, and that works, until you commit your dotfiles, back them up somewhere, or share your screen with the file open.

If you’re a Mac user, macOS has an encrypted secret store that unlocks when you log in: the keychain. The security command lets you use it from the shell; your secret lives in the keychain and your dotfiles only have a lookup.

storing a secret

security add-generic-password -a "$USER" -s github-token -w
  • -a is the account name. For portability, $USER is a sensible default.
  • -s is the service name. Think of it as the secret’s name; it’s what you’ll use to look it up later.
  • -w with nothing after it, as the last option, makes security prompt for the value.

You can pass the value directly (-w ghp_abc123), but it ends up in your shell history, and is briefly visible in the process list; it’s better to let it prompt you.

If the item already exists, add-generic-password fails. Add -U to update it in place instead, which makes rotating a token a safe, repeatable command:

security add-generic-password -U -a "$USER" -s github-token -w

reading it back

security find-generic-password -a "$USER" -s github-token -w

Here -w means “print only the password.” Without it you get a pile of metadata and no secret.

using it as an env var

The simple version, in .zshrc:

export GITHUB_TOKEN="$(security find-generic-password -a "$USER" -s github-token -w)"

But… now every shell (and every process started from it) has the token in its environment, and each new shell pays for a keychain lookup at startup. If you only need a secret for one tool, you can scope it to that command only, using a function with the same name:

gh () {
  GITHUB_TOKEN="$(security find-generic-password -a "$USER" -s github-token -w)" command gh "$@"
}

Now the token only exists for the lifetime of that gh process.

If you’ve got several of these, a small helper keeps it readable:

get-key () {
  security find-generic-password -a "$USER" -s "$1" -w
}

export OPENAI_API_KEY="$(get-key openai-api-key)"

cleaning up

security delete-generic-password -a "$USER" -s github-token

You can also see and edit these items in Keychain Access; search for the service name.

things to know

  • It’s tied to your login keychain. It’s unlocked when you’re logged in at the console. Over SSH it may be locked, and lookups will fail until you run security unlock-keychain.
  • Access control is per app. By default the app that created the item (here, /usr/bin/security) can read it without prompting. Other apps asking for it will trigger a keychain dialog.
  • It doesn’t protect you from yourself. Once the value is in an environment variable, any process running as you can read it. The win here is keeping secrets out of plaintext files, git history, and backups, not sandboxing them at runtime.
  • It’s macOS only. If your dotfiles are shared with Linux machines, guard the lookup with command -v security >/dev/null or a uname check.