Your CLI needs an API token on every run, and asking for it every time is not an option. The quick fix is to write it into ~/.config/mytool/config.toml — and from that moment the token is in every backup of that directory, in the dotfiles repository the user syncs to GitHub, and in the config they paste into a support ticket. Every major operating system already provides a place designed for exactly this: an encrypted credential store unlocked by the user's login. The keyring package gives Python one API over all of them. This guide builds auth login, auth status and auth logout commands on keyring, handles machines where no keychain exists, and tests it all without touching the real keychain. It is part of the secrets and credentials topic.
Prerequisites
- Python 3.10+,
keyring25+ (uv add keyring) and Typer. - A token to store — obtained from a web UI, or from a login flow such as the OAuth device flow.
- On Linux desktops, a Secret Service provider (GNOME Keyring or KWallet), which most desktop distributions run by default.
What keyring talks to
keyring selects a backend for the current platform automatically:
Each backend stores credentials encrypted at rest, keyed by a service name and a username. The operating system unlocks the store with the user's login, so there is nothing for your tool to encrypt and no key for it to protect. The value never appears in a file your tool writes.
The awkward case is the last row. On servers, in containers, in CI, and in WSL without a desktop session, there is often no backend at all. keyring then selects its fail.Keyring backend, whose operations raise NoKeyringError. A CLI must detect that and fall back to something reasonable — usually environment variables — rather than crashing with a traceback.
The recipe
Keep keychain access in one module. The service name identifies your tool; the "username" slot holds the profile name, so each profile gets its own entry (see supporting multiple profiles and accounts).
# src/mytool/tokenstore.py
from __future__ import annotations
import keyring
from keyring.backends import fail
from keyring.errors import KeyringError, PasswordDeleteError
SERVICE = "mytool"
class StoreUnavailable(Exception):
pass
def backend_name() -> str | None:
"""Human-readable backend name, or None when no usable keychain exists."""
kr = keyring.get_keyring()
if isinstance(kr, fail.Keyring):
return None
return getattr(kr, "name", type(kr).__name__)
def save(profile: str, token: str) -> str:
name = backend_name()
if name is None:
raise StoreUnavailable("no system keychain is available on this machine")
try:
keyring.set_password(SERVICE, profile, token)
except KeyringError as exc:
raise StoreUnavailable(f"could not write to {name}: {exc}") from exc
return name
def load(profile: str) -> str | None:
if backend_name() is None:
return None
try:
return keyring.get_password(SERVICE, profile)
except KeyringError:
return None # locked or denied: treat as absent
def delete(profile: str) -> bool:
try:
keyring.delete_password(SERVICE, profile)
return True
except (PasswordDeleteError, KeyringError):
return False
# src/mytool/auth.py
import os
import sys
import typer
from mytool import tokenstore
auth = typer.Typer(help="Manage stored credentials.")
def _tail(token: str) -> str:
return f"...{token[-4:]}" if len(token) >= 12 else "(short token)"
@auth.command()
def login(
profile: str = typer.Option("default", "--profile", "-p"),
token_stdin: bool = typer.Option(False, "--token-stdin", help="Read the token from stdin."),
) -> None:
"""Store an API token in the system keychain."""
if token_stdin:
token = sys.stdin.read().strip()
elif sys.stdin.isatty():
token = typer.prompt("API token", hide_input=True, err=True).strip()
else:
typer.echo("error: no terminal to prompt on; use --token-stdin", err=True)
raise typer.Exit(2)
if not token:
typer.echo("error: empty token", err=True)
raise typer.Exit(2)
try:
where = tokenstore.save(profile, token)
except tokenstore.StoreUnavailable as exc:
typer.echo(f"error: {exc}", err=True)
typer.echo("hint: set MYTOOL_TOKEN or MYTOOL_TOKEN_FILE instead", err=True)
raise typer.Exit(1)
typer.echo(f"stored token for profile {profile!r} in {where}", err=True)
@auth.command()
def status(profile: str = typer.Option("default", "--profile", "-p")) -> None:
"""Show which credential would be used, without revealing it."""
if os.environ.get("MYTOOL_TOKEN"):
typer.echo(f"profile {profile!r}: token from MYTOOL_TOKEN ({_tail(os.environ['MYTOOL_TOKEN'])})")
return
token = tokenstore.load(profile)
backend = tokenstore.backend_name() or "no keychain available"
if token:
typer.echo(f"profile {profile!r}: token from {backend} ({_tail(token)})")
else:
typer.echo(f"profile {profile!r}: not logged in ({backend})")
raise typer.Exit(1)
@auth.command()
def logout(profile: str = typer.Option("default", "--profile", "-p")) -> None:
"""Remove the stored token."""
removed = tokenstore.delete(profile)
typer.echo(f"{'removed' if removed else 'no stored'} token for profile {profile!r}", err=True)
Mount the sub-app in your main CLI with app.add_typer(auth, name="auth"), the pattern described in building a CLI with subcommands in Click and its Typer equivalents.
Design notes
The token never touches argv. login reads from a hidden prompt or from stdin; there is deliberately no --token VALUE option. --token-stdin lets scripts and password managers pipe a token in (op read op://vault/api/token | mytool auth login --token-stdin) without it appearing in the process list — the same design as docker login --password-stdin.
Status shows a tail, not the token. The last four characters are enough to tell two tokens apart and not enough to use one. Reporting where the token came from — keychain or environment — resolves most "why is it using the wrong account?" questions.
Locked keychains are "absent", not errors. On macOS the user may deny access; on Linux the collection may be locked and the unlock dialog dismissed. Treating that as "no token" lets the normal fallback and error messages take over.
Reads can prompt. The first time a new binary reads a Keychain item on macOS, the user may be asked to allow access. That is a feature — the operating system is protecting the item — but it means reads can block on user interaction. Never read the keychain in non-interactive contexts where an environment variable would do.
UX considerations
- Environment beats keychain. For CI and one-off overrides,
MYTOOL_TOKENshould win over the stored token. Make the precedence visible inauth status. - Fail helpfully on headless machines. "no system keychain is available on this machine — set MYTOOL_TOKEN or MYTOOL_TOKEN_FILE instead" is actionable; a
NoKeyringErrortraceback is not. Guidance on the environment route is in reading secrets from env and files. - Do not silently fall back to plain-text files. The
keyrings.altpackage offers file-based backends, some with weak or no encryption. If you offer one, make it an explicit opt-in and say what it does and does not protect. - Make logout complete. Remove the keychain entry, and if the API supports token revocation, revoke it server-side too, so a copy that leaked elsewhere stops working.
- Name the service after the tool. Users see the service name in Keychain Access or Credential Manager;
mytoolis recognisable,python-keyring-defaultis not.
Testing the behaviour
Never let tests touch the developer's real keychain. keyring.set_keyring() installs any backend object, so an in-memory backend in a fixture isolates every test:
# tests/test_auth.py
import keyring
import pytest
from keyring.backend import KeyringBackend
from keyring.backends import fail
from typer.testing import CliRunner
from mytool.auth import auth
runner = CliRunner()
class MemoryKeyring(KeyringBackend):
priority = 1
name = "memory"
def __init__(self):
self.data = {}
def get_password(self, service, username):
return self.data.get((service, username))
def set_password(self, service, username, password):
self.data[(service, username)] = password
def delete_password(self, service, username):
if (service, username) not in self.data:
raise keyring.errors.PasswordDeleteError("missing")
del self.data[(service, username)]
@pytest.fixture
def kr(monkeypatch):
monkeypatch.delenv("MYTOOL_TOKEN", raising=False)
backend = MemoryKeyring()
keyring.set_keyring(backend)
return backend
def test_login_status_logout(kr):
token = "tok_1234567890abcdef"
assert runner.invoke(auth, ["login", "--token-stdin"], input=token + "\n").exit_code == 0
assert kr.data[("mytool", "default")] == token
result = runner.invoke(auth, ["status"])
assert result.exit_code == 0
assert "...cdef" in result.output and token not in result.output
assert runner.invoke(auth, ["logout"]).exit_code == 0
assert runner.invoke(auth, ["status"]).exit_code == 1
def test_env_overrides_keychain(kr, monkeypatch):
kr.set_password("mytool", "default", "tok_from_keychain_0000")
monkeypatch.setenv("MYTOOL_TOKEN", "tok_from_env_99999999")
assert "MYTOOL_TOKEN" in runner.invoke(auth, ["status"]).output
def test_no_backend_is_explained(monkeypatch):
monkeypatch.delenv("MYTOOL_TOKEN", raising=False)
keyring.set_keyring(fail.Keyring())
result = runner.invoke(auth, ["login", "--token-stdin"], input="tok_abcdefghijklmnop\n")
assert result.exit_code == 1
assert "MYTOOL_TOKEN" in result.output
The final test pins the headless-machine behaviour: with the fail backend active, login explains the alternative instead of crashing. For a CI job that needs a keychain for an integration test, keyrings.alt provides simple backends you can configure through PYTHON_KEYRING_BACKEND.
Conclusion
The operating system already has a secure place for your CLI's tokens; keyring makes using it a three-function affair. Wrap it in a small module that detects a missing backend, give users auth login (from a hidden prompt or stdin, never an argument), auth status (source and tail, never the value) and auth logout, and let environment variables override the keychain for automation. Test with an in-memory backend, and the whole credential lifecycle is covered without ever writing a token to disk yourself.
Frequently asked questions
Is the keychain safe from other programs run by the same user?
Partially. macOS and Windows can restrict items to the application that created them or prompt on access, which helps. The Linux Secret Service generally allows any program in the unlocked session to read items. The keychain protects against files leaking, backups and casual access — not against malware running as the user, which nothing in a CLI can fully prevent.
Can I store more than a token, such as a refresh token and expiry?
Yes: store a small JSON document as the "password", or use separate entries (profile and profile:refresh). Some Windows Credential Manager configurations limit value size to a few kilobytes, so keep it compact.
Why does macOS ask for access every time after I upgrade my CLI?
Keychain access-control lists are tied to the calling binary. When a Python interpreter or a frozen executable changes, macOS treats it as a new application. Users can choose "Always Allow"; signed binaries with a stable identity avoid the repeat prompts.
How do I use keyring inside Docker?
Usually you should not: containers rarely run a Secret Service, and the whole point of a container is injected configuration. Pass the token with MYTOOL_TOKEN_FILE pointing at a mounted secret, and let the keychain path apply only on workstations.