Command-line tools handle some of the most sensitive strings on a developer's machine: API tokens with write access to production, cloud credentials, database passwords, signing keys. They also have more ways to leak them than almost any other kind of software. Arguments show up in the process list and shell history. Config files end up in dotfile repositories and backups. Debug logs get pasted into issue trackers. Tracebacks print local variables. Environment variables are inherited by every child process the tool starts. None of these leaks requires an attacker — just a normal workday and a tool that was not designed with them in mind.
This topic covers handling credentials in a Python CLI so that the safe path is the default one: storing tokens in the operating system's keychain, accepting secrets from environment variables and mounted files for automation, prompting securely when a person is present, redacting secrets from everything the tool prints, and supporting several accounts without mixing them up. It belongs to the CLI Runtime & Systems Integration section and underpins calling HTTP APIs, where most of those tokens are used.
TL;DR
- Never accept a secret as a plain command-line argument. Use an environment variable, a file path, stdin or a prompt.
- Store long-lived tokens in the system keychain via
keyring, not in a config file. Keep non-secret settings in config. - Support automation explicitly:
MYTOOL_TOKENandMYTOOL_TOKEN_FILEfor CI and containers. - Prompt only when a person is present, with no echo, and fail with instructions otherwise.
- Wrap secrets in a type that cannot print itself, and add a redacting filter to logging as a second line of defence.
- Make the active account visible, and add friction to destructive commands against production profiles.
Where CLIs leak secrets
Before the techniques, the threat model. For most internal tools the danger is not a targeted attacker but ordinary exposure: a secret ending up somewhere it is readable by more people, for longer, than intended.
Command-line arguments are the worst offender. On Linux, every user on the machine can read every process's arguments through ps or /proc for as long as the process runs. Shells record them in history files. CI systems echo commands into build logs. A --token or --password option invites every one of these leaks, which is why well-designed tools such as docker login warn when a password is passed on the command line and offer --password-stdin instead.
Configuration files are designed to be copied: into backups, into dotfile repositories published on GitHub, into support tickets ("here is my config"). A token stored in ~/.config/mytool/config.toml inherits all of that.
Logs, debug output and tracebacks are where secrets leak most often in practice, because they are produced precisely when something is wrong and are then shared with the people helping to fix it. HTTP debug logging prints Authorization headers; exceptions include URLs with embedded credentials; rich tracebacks with show_locals=True print every local variable.
The environment is inherited by every subprocess. A tool that sets AWS_SECRET_ACCESS_KEY for its own use and then runs npm install has just given that key to every install script of every dependency.
Where a credential should come from
A CLI needs to serve two kinds of user with one code path: a person at a terminal, who should log in once and forget about it, and automation — CI jobs, containers, cron — which cannot interact and needs to inject credentials from a secret manager. Resolving credentials from an ordered list of sources satisfies both:
# src/mytool/credentials.py
from __future__ import annotations
import hmac
import os
import sys
from getpass import getpass
from pathlib import Path
import keyring
from keyring.errors import KeyringError
SERVICE = "mytool"
class Secret:
"""A string that will not print itself."""
__slots__ = ("_value",)
def __init__(self, value: str) -> None:
self._value = value
def reveal(self) -> str:
return self._value
def __str__(self) -> str:
return "********"
def __repr__(self) -> str:
return "Secret('********')"
def __eq__(self, other: object) -> bool:
return isinstance(other, Secret) and hmac.compare_digest(self._value, other._value)
def __hash__(self) -> int:
return hash(self._value)
class NoCredential(Exception):
pass
def resolve_token(profile: str, token_file: Path | None = None,
allow_prompt: bool = True) -> tuple[Secret, str]:
"""Return (token, source), trying each source in precedence order."""
if token_file is not None:
return Secret(token_file.read_text(encoding="utf-8").strip()), f"file {token_file}"
if path := os.environ.get("MYTOOL_TOKEN_FILE"):
return Secret(Path(path).read_text(encoding="utf-8").strip()), "MYTOOL_TOKEN_FILE"
if value := os.environ.get("MYTOOL_TOKEN"):
return Secret(value), "MYTOOL_TOKEN"
try:
if value := keyring.get_password(SERVICE, profile):
return Secret(value), "keychain"
except KeyringError:
pass # no usable backend: fall through
if allow_prompt and sys.stdin.isatty():
return Secret(getpass(f"API token for profile {profile!r}: ")), "prompt"
raise NoCredential(
f"no token for profile {profile!r}: run 'mytool auth login', "
"or set MYTOOL_TOKEN / MYTOOL_TOKEN_FILE"
)
Returning the source alongside the token pays for itself the first time someone asks "why is it using the wrong account?" — a --verbose line saying "token from MYTOOL_TOKEN" answers it instantly. The individual sources each have a dedicated guide: storing tokens with keyring, reading secrets from env and files and prompting for passwords securely.
A type that cannot leak
The Secret wrapper above is small, but it changes the default. An f-string, a log call, a print(settings) for debugging or a traceback showing local variables all see ********. Getting at the real value requires calling .reveal(), which you do in exactly one or two places — building the Authorization header, passing a password to a database driver — and which is easy to audit with a search.
Wrap secrets at the moment they are read and unwrap them at the moment they are used. Everything in between — settings objects, context objects, function arguments — carries the wrapper. If you already use pydantic, SecretStr provides the same behaviour; pydantic-settings reads environment variables straight into it, as covered in typed settings with pydantic-settings. Comparing with hmac.compare_digest avoids leaking a secret's contents through timing, which matters little in a CLI but costs nothing.
Storing long-lived credentials
Users should log in once, not paste a token on every command. The right place to keep that token is the operating system's credential store — the macOS Keychain, Windows Credential Manager, or the Secret Service on Linux desktops — which encrypts at rest, is unlocked by the user's login, and is not part of any file your tool writes. The keyring package gives one API over all of them:
import keyring
keyring.set_password("mytool", "prod", token) # mytool auth login
token = keyring.get_password("mytool", "prod") # every other command
keyring.delete_password("mytool", "prod") # mytool auth logout
Two practical complications deserve planning. Headless Linux machines — servers, containers, WSL without a desktop session — often have no keyring backend, and keyring raises an error there; your tool should fall back to environment variables and say so. And some organisations forbid storing tokens at all on shared machines; an environment-only mode respects that. The keyring guide covers backend detection, the fallbacks and auth status and logout commands.
Automation: environment variables and secret files
CI systems and container orchestrators deliver secrets in two ways: as environment variables (GitHub Actions secrets, GitLab CI variables) or as files mounted into the container (Docker and Kubernetes secrets under /run/secrets). Support both. The environment variable is the most convenient; the file is safer, because it is not inherited by child processes and can be rotated without restarting a long-running process.
The convention popularised by official Docker images is a paired variable with a _FILE suffix: MYTOOL_TOKEN holds the value, MYTOOL_TOKEN_FILE holds a path to read it from. Operators already know the pattern, and supporting it costs three lines, as in the resolver above.
When your CLI starts subprocesses, pass them an explicit environment containing only what they need, rather than inheriting everything. A tool that runs third-party build scripts with the user's cloud credentials in the environment is handing those credentials to code nobody reviewed. The mechanics are in running subprocesses from Python CLIs.
Prompting when a person is present
When no stored or injected credential exists and a person is at the terminal, prompting is reasonable — for a password to unlock a local vault, or a one-off token. Use getpass.getpass() (or typer.prompt(..., hide_input=True)), which disables echo and reads from the controlling terminal. And never prompt when nobody is there: a prompt in a CI job does not fail, it hangs until the job times out. Check sys.stdin.isatty(), respect a --no-input flag, and when prompting is impossible, fail immediately with a message naming the environment variable to set. The full treatment, including confirmation for new passwords and --password-stdin, is in prompting for passwords securely.
Keeping secrets out of output
The Secret type protects values you control. It cannot protect values that other libraries print: httpx's debug logging of request headers, a database driver's connection-string error, a URL with user:password@ in an exception message. For those, add a redacting filter to your logging configuration that masks registered secret values and well-known token patterns before any handler writes the record:
import logging
import re
TOKEN_PATTERNS = [
re.compile(r"(Bearer\s+)[A-Za-z0-9._~+/-]+=*"),
re.compile(r"(://[^:/\s]+:)[^@\s]+(@)"),
re.compile(r"\b(ghp_|gho_|xoxb-|xoxp-)[A-Za-z0-9-]+"),
]
class RedactingFilter(logging.Filter):
def __init__(self, secrets: list[str]) -> None:
super().__init__()
self.secrets = [s for s in secrets if len(s) >= 6]
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
for s in self.secrets:
message = message.replace(s, "****")
for pattern in TOKEN_PATTERNS:
message = pattern.sub(lambda m: m.group(1) + "****" + (m.group(2) if m.lastindex == 2 else ""), message)
record.msg, record.args = message, None
return True
Attach it to the handlers, not a single logger, so it covers third-party libraries as well as your own code. Redacting secrets from CLI output and logs builds it out with tests, covers tracebacks and Rich's show_locals, and deals with the --debug flag users turn on right before they file a bug report. The logging setup it plugs into is described in structured logging for CLI apps.
Secrets in CI pipelines
CI is where a CLI's credential handling is tested hardest, because everything the tool prints is stored in a build log that many people can read, often for months. Most CI systems mask the values of secrets they injected — GitHub Actions replaces registered secrets with *** in logs — but that masking is string matching on output, and it fails in predictable ways:
- Transformed secrets are not masked. If your tool base64-encodes a token, URL-encodes it, or prints only part of it, the CI system does not recognise it. A
Basicauth header is the base64 ofuser:passwordand will appear in full. - Derived secrets are unknown to CI. A short-lived token your CLI obtains by exchanging the injected one was never registered, so it is printed verbatim if it is printed at all.
- Multi-line secrets mask badly. Private keys and JSON service-account files are often split across lines in ways the masker misses.
The defences are the ones already described, applied without exceptions: never print a credential in any form, keep debug HTTP logging behind an explicit flag with redaction on, and wrap derived tokens in the Secret type as soon as you receive them. For GitHub Actions specifically, a tool can register a derived value for masking by printing ::add-mask::<value> to stdout before anything else could print it — useful when your CLI is designed to run in Actions and exchanges tokens.
Finally, give automation a way to use the tool without any interactive or stored state: every command should work with only MYTOOL_TOKEN or MYTOOL_TOKEN_FILE set, and --no-input (or detection of a non-terminal stdin) should turn any would-be prompt into an immediate, explanatory failure. The pipeline configuration side of this lives in CI/CD pipelines for Python CLIs.
Several accounts, one tool
Developers routinely have more than one identity for the same service: personal and work, staging and production, several customer tenants. A tool that supports only one credential forces them to log out and in repeatedly, and makes it far too easy to run a command against the wrong one. Profiles solve both: named bundles of non-secret settings (URL, account, region) in the config file, each with its own token in the keychain under a key derived from the profile name, selected with the usual precedence — --profile flag, MYTOOL_PROFILE environment variable, configured default. Supporting multiple profiles and accounts implements it, including a "protected" marker that makes destructive commands against production ask for confirmation.
Testing credential handling
Credential code is security-relevant, so it deserves direct tests — and those tests must never touch the real keychain or environment. keyring can be pointed at an in-memory backend, and monkeypatch controls the environment:
import keyring
import pytest
from keyring.backend import KeyringBackend
from mytool.credentials import NoCredential, Secret, resolve_token
class MemoryKeyring(KeyringBackend):
priority = 1
def __init__(self) -> None:
self.store: dict[tuple[str, str], str] = {}
def get_password(self, service, username):
return self.store.get((service, username))
def set_password(self, service, username, password):
self.store[(service, username)] = password
def delete_password(self, service, username):
self.store.pop((service, username), None)
@pytest.fixture(autouse=True)
def clean(monkeypatch):
monkeypatch.delenv("MYTOOL_TOKEN", raising=False)
monkeypatch.delenv("MYTOOL_TOKEN_FILE", raising=False)
kr = MemoryKeyring()
keyring.set_keyring(kr)
return kr
def test_env_beats_keychain(monkeypatch, clean):
clean.set_password("mytool", "prod", "from-keychain")
monkeypatch.setenv("MYTOOL_TOKEN", "from-env")
token, source = resolve_token("prod", allow_prompt=False)
assert token.reveal() == "from-env" and source == "MYTOOL_TOKEN"
def test_secret_never_prints():
s = Secret("hunter2hunter2")
assert "hunter2" not in f"{s} {s!r} {[s]}"
def test_missing_credential_explains(clean):
with pytest.raises(NoCredential, match="MYTOOL_TOKEN"):
resolve_token("prod", allow_prompt=False)
Key takeaways
- Never take secrets as plain arguments; offer env vars,
_FILEvariables,--*-stdinflags and prompts. - Keep tokens in the system keychain via
keyring, with a deliberate fallback for headless machines. - Resolve credentials from an ordered list of sources and report which one won.
- Wrap secrets in a type that masks itself, and unwrap only where the value is used.
- Add a redacting logging filter for everything other libraries print.
- Pass child processes only the environment they need.
- Support named profiles, show which is active, and add friction for production.
Frequently asked questions
Is an environment variable really safer than a command-line argument?
Yes, meaningfully. Arguments are readable by every user on the machine and land in shell history and CI logs; a process's environment is readable only by the same user (and root) and is not recorded in history. Files with restrictive permissions are safer still, which is why the _FILE convention exists.
Should my CLI encrypt its config file instead of using a keychain?
Encrypting a file requires a key, and the key has to live somewhere — usually right next to the file, which protects nothing. The operating system's keychain solves the key problem with the user's login. Use a file-based encrypted store only where no keychain exists, and be honest in the docs about what it protects against.
How long should stored tokens live?
As short as the API allows while remaining convenient: prefer short-lived access tokens with a refresh token (as the OAuth device flow provides) over a long-lived personal access token. Show the expiry in auth status so users are not surprised.
What should happen when a token is rejected?
Say so clearly, name the profile and the source of the token, and tell the user how to fix it ("token from keychain for profile prod was rejected — run: mytool auth login --profile prod"). Do not silently fall through to the next source; that hides the real problem.
Do I need to worry about secrets in memory?
For a typical CLI, no. Python cannot reliably wipe strings from memory, and an attacker who can read your process memory has already won. Focus on the channels above, which are where real leaks happen.
Related
- Up: CLI Runtime & Systems Integration
- Down: Storing tokens with keyring
- Down: Reading secrets from env and files
- Down: Prompting for passwords securely
- Down: Redacting secrets from CLI output and logs
- Down: Supporting multiple profiles and accounts
- Sideways: Calling HTTP APIs from Python CLIs
- Sideways: Handling configuration files and environment variables