Runtime

Storing CLI Tokens Securely with keyring

Keep a Python CLI’s API tokens in the macOS Keychain, Windows Credential Manager or Secret Service with keyring: login, status, logout, fallbacks and tests.

Updated

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+, keyring 25+ (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:

What keyring talks to The operating system credential store that the keyring package uses on macOS, Windows and Linux, and the fallback on headless machines. What keyring talks to Platform Backend Unlocked by macOS Keychain user login Windows Credential Manager user login Linux desktop Secret Service (GNOME Keyring, KWallet) desktop session Headless Linux / CI often none available use env vars instead Detect the "no backend" case and fall back deliberately rather than crashing.

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.

Storing and reading a token The login command stores a token in the system keychain through keyring; a later command reads it back, and the keychain may ask the user to unlock it. Storing and reading a token mytool login keyring OS keychain mytool deploy set_password(svc, user, tok) store, encrypted at rest get_password(svc, user) read (may prompt to unlock) token The token never touches a file your tool writes, so it cannot end up in a backup or a dotfiles repo.

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

Login and status commands Terminal output of login, status and logout commands for a CLI that stores its token in the system keychain. Login and status commands bash $ mytool auth login --token-stdin < token.txt stored token for ana@example.com in macOS Keychain $ mytool auth status logged in as ana@example.com (token from: keychain, ends ...9f2c) $ mytool auth logout removed token for ana@example.com Show where the credential came from and its last few characters, never the whole thing.
  • Environment beats keychain. For CI and one-off overrides, MYTOOL_TOKEN should win over the stored token. Make the precedence visible in auth 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 NoKeyringError traceback 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.alt package 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; mytool is recognisable, python-keyring-default is 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.