DEV Community

Cover image for Where should a CLI keep your API keys?
Matt Cockayne
Matt Cockayne

Posted on • Originally published at phpboyscout.uk

Where should a CLI keep your API keys?

Your CLI tool needs the user's API key. It has to come from somewhere, and it has to survive between runs, so the obvious move is to ask once and write it into the config file. One tidy api_key: line. Job done.

It works beautifully on the first afternoon. And then, months later, it's quietly become a liability nobody actually decided to create.

The config file that quietly becomes a liability

Your CLI tool needs the user's API key. It has to come from somewhere, and it has to survive between invocations, so the obvious move is to ask once and write it into the tool's config file. ~/.config/yourtool/config.yaml, a nice api_key: line, done.

It works on the first afternoon. It keeps working. And then, slowly, it becomes a problem nobody decided to create.

The config file gets committed to a dotfiles repo. It gets caught in a tar of someone's home directory that lands in a backup bucket. It scrolls past in a screen share. It sits, world-readable, on a shared build box. None of these are exotic. They're just a Tuesday. The plaintext key was fine right up until the file went somewhere the key shouldn't, and config files go places.

I didn't want go-tool-base handing every tool built on it that same slow-motion liability by default. So credential handling got rebuilt around a simple idea: the config file should usually hold a reference to the secret, not the secret itself.

Three modes, and which one you get

go-tool-base supports three ways to store a credential.

Environment-variable reference, the default. The config records the name of an environment variable, not its value:

anthropic:
  api:
    env: ANTHROPIC_API_KEY
Enter fullscreen mode Exit fullscreen mode

The secret itself lives in your shell profile, your direnv setup, or your CI platform's secret store, wherever you already keep that sort of thing. The config file now contains nothing sensitive at all. You can commit it, back it up, paste it into a bug report. The reference is inert on its own.

OS keychain, opt-in. The config holds a <service>/<account> reference and the actual secret goes into the operating system's keychain: macOS Keychain, GNOME Keyring or KWallet via the Secret Service, Windows Credential Manager.

anthropic:
  api:
    keychain: mytool/anthropic.api
Enter fullscreen mode Exit fullscreen mode

This one is opt-in by design, because the keychain backend carries dependencies that some deployments simply aren't allowed to ship. (That opt-in mechanism turned out to be an interesting little problem all of its own, and it gets its own post in a couple of days.)

Literal value, legacy and grudging. The old behaviour. The secret sits in the config in plaintext:

anthropic:
  api:
    key: sk-ant-...
Enter fullscreen mode Exit fullscreen mode

It still works, because breaking every existing tool's config on an upgrade would be its own kind of vandalism. But it's the last resort, it's documented as the last resort, and the setup wizard puts a warning in front of you when you pick it.

The one place literal mode is not allowed

There's a single hard "no" in all of this. If go-tool-base detects it's running in CI (CI=true, which every major CI platform sets) the setup flow will refuse to write a literal credential, and exits non-zero.

The reasoning is that a plaintext secret written during a CI run is a plaintext secret written onto an ephemeral, often shared, frequently-logged machine, by an automated process that no human is watching. That's the exact situation where the slow-motion liability becomes a fast one. CI environments inject secrets as environment variables already; there's no good reason for a tool to be writing one to disk there, so go-tool-base simply won't.

How it decides at runtime

A credential can be configured more than one way at once. You might have an env reference and an old literal key still lurking. So resolution follows a fixed precedence, highest to lowest:

  1. The *.env reference. If that env var is set, use it.
  2. Otherwise the *.keychain reference. If a keychain entry resolves, use it.
  3. Otherwise the literal *.key / *.value, the legacy path.
  4. Otherwise a well-known fallback env var (ANTHROPIC_API_KEY and friends), so a tool still picks up the ecosystem-standard variable with no config at all.

The useful property here is that adding a more secure mode transparently wins. Drop an env reference next to an old literal key and the next run uses the env var. You can migrate a credential to a better home without first removing it from its worse one, which makes the migration safe to do incrementally instead of as one nervous big-bang edit.

The tool tells on itself

A precedence rule is no use if nobody knows their config still has a plaintext key three layers down. So the built-in doctor command grew a check for exactly that. Run doctor, and if any literal credential is sitting in your config it reports a warning, names the offending keys (the key names, never the values) and points you at how to migrate.

It's not an error. Literal mode is still legal. But the tool will quietly keep reminding you that you left the campsite messier than you could have, until you go and tidy it. (Old Scout habits die hard, and they've leaked all the way into the framework.)

The gist

A CLI tool that writes your API key into a plaintext config file isn't doing anything wrong, exactly. It's just handing you a liability that activates later, when the file travels somewhere the key shouldn't. go-tool-base's answer is three storage modes: an env-var reference by default, the OS keychain on request, and a plaintext literal only as a documented last resort that CI environments can't use at all. Runtime resolution runs in a fixed precedence so a more secure mode always wins, which makes migrating a credential safe to do gradually. And doctor keeps an eye on the config so a stray plaintext secret doesn't get to hide forever.

The secret should live in a secret store. The config file should just know its name.


Originally published at phpboyscout.uk on 20 April 2026.

Top comments (6)

Collapse
 
peterbuildssecure profile image
Peter

One subtle issue with the precedence-based migration: changing which credential wins does not remove the old exposure.

If a literal key remains valid in the config, adding an environment-variable reference means execution is safer, but the old secret is still available to dotfile repositories, backups and diagnostic bundles. I’d consider migration complete only after resolving and validating the replacement, removing the literal atomically, and rotating or revoking the old credential. The doctor warning is useful, but it is detecting an active credential leak rather than harmless dead configuration.

I’d also consider different defaults by environment. Injected environment variables are a good CI interface, but on an interactive machine exported variables are inherited by every child process. An OS keychain is usually the better desktop default, with environment references reserved for CI and intentionally scoped sessions.

Collapse
 
phpboyscout profile image
Matt Cockayne

Absolutely right, couldnt agree more! any migration effort comes with risks and unfortunatly compromises are often required. Hence pointing it out clearly in the post. I always much prefer a hard cutover personally, but for some that's not always feasible.

The doctor warning is intended to be just that, a warning, it's it's intended to be a precursor to to proper credentials leak detection.

As for dead configuration, I'm working on a feature for my config library that at the moment that might help with that.

Thanks for your input, much appreciated

Collapse
 
peterbuildssecure profile image
Peter

A staged migration can still preserve a hard invariant: once the secure store has been initialized successfully, plaintext fallback must never silently become active again.

I’d make doctor report the credential source, flag every lower-precedence copy, and return a failing exit code in CI when deprecated storage remains. The migration command can then remove the old value atomically and verify that a fresh process still authenticates before declaring success. That keeps the compatibility window explicit instead of letting it become permanent.

Thread Thread
 
phpboyscout profile image
Matt Cockayne

Thanks for coming back on this, that second comment genuinely shifted how I was thinking about it.

I've raised four tickets off the back of your two, and the invariant you describe is the piece I didn't have: once a secure store is established, plaintext should never silently win again. Precedence is what makes incremental migration safe and I'd like to keep that, but it does mean a locked keychain drops you back to a literal without ever saying so. That's a ticket of its own now.

Couple of things I turned up while writing them up, in case they're useful.

The migrate command already does the atomic removal you're after. It stages the changes and commits them in a single transactional apply, so a partial failure leaves the file untouched. What it doesn't do is confirm the replacement actually resolves before it removes the literal, or say anything at all about rotation. You're right that rotation is the only step that does anything about the copies already sitting in someone's backups.

The second one is slightly embarrassing. My config library already exposes exactly what you asked doctor to report: which layer won, and every lower-precedence layer still holding a copy. Two sibling commands use it. Doctor doesn't. So that suggestion needs no new code anywhere, just doctor using what's already sitting there.

The failing exit code is going in too. You're right that a warning nobody can gate on ends up permanent.

Really appreciate the time you've put into this.

Thread Thread
 
peterbuildssecure profile image
Peter

That sounds like a solid outcome. The resolve-before-delete check is the important remaining piece because atomicity guarantees consistent state, but not usable state.

I’d make the migration sequence: write to the secure store, read it back through the exact resolution path the application uses, compare a non-secret fingerprint, and only then remove the plaintext value. Once a migration marker exists, an unavailable or locked secure store should be a hard error rather than silently reactivating an older fallback.

Recording the credential version or fingerprint would also let doctor identify copies that predate a rotation without printing the credential itself.

Collapse
 
lunarose profile image
Luna Rose

Great reminder that secrets should have a home, and config files are usually not that home.