Runtime

Prompting for Passwords Securely in Python CLIs

Prompt for passwords and tokens in a Python CLI without echo or hangs: getpass and hide_input, confirmation, --password-stdin, --no-input and testable prompts.

Updated

Some secrets have to be typed by a person: the passphrase that unlocks a local vault, the password for a database the tool is about to configure, a one-time token pasted from a web page. Prompting for them looks trivial — input("Password: ") — and gets several things wrong at once: the password is echoed to the screen for anyone looking over a shoulder, the prompt is written to stdout where a pipe may capture it, and when the command runs in CI the prompt waits forever for a keyboard that does not exist. This guide builds prompt helpers that hide input, confirm new passwords, fall back to stdin for automation, refuse to hang when nobody is there, and can be tested without a terminal. It is part of the secrets and credentials topic.

Prerequisites

  • Python 3.10+ and Typer (Click works identically — Typer's prompt is Click's).
  • A command that genuinely needs interactive secret input. If the secret can be stored or injected instead, prefer the keychain or environment variables and files.

When a prompt is allowed at all

The first decision is not how to prompt but whether to. A prompt only makes sense when a person is attached to the terminal and has not asked the tool to be non-interactive.

Can this command prompt? A decision for whether a CLI may prompt for a password: only when stdin is a terminal and prompting was not disabled; otherwise fail with instructions. Can this command prompt? Is stdin a terminal, and is --no-input off? Yes — a person is there getpass prompt no echo, confirm twice No — a pipe, CI or cron Fail clearly name the env var to set A prompt in CI does not fail — it hangs until the job times out.

sys.stdin.isatty() answers the first half: it is False under CI, cron, systemd, docker run without -t, and whenever input is piped. A --no-input flag (or an environment variable such as CI=true) answers the second — some users run tools in a terminal but inside scripts where a surprise prompt is just as unwelcome. When prompting is not allowed, fail immediately, with a message saying which non-interactive option to use instead. A hanging CI job that times out after an hour is the most expensive possible way to report "missing password".

The recipe

# src/mytool/prompts.py
from __future__ import annotations

import getpass
import os
import sys


class PromptUnavailable(Exception):
    pass


def can_prompt(no_input: bool = False) -> bool:
    return (not no_input and sys.stdin.isatty()
            and os.environ.get("MYTOOL_NO_INPUT", "").lower() not in ("1", "true", "yes"))


def ask_secret(label: str, *, no_input: bool = False, hint: str = "") -> str:
    """Prompt once for an existing secret, without echo."""
    if not can_prompt(no_input):
        raise PromptUnavailable(f"cannot prompt for {label} here; {hint}".rstrip("; "))
    value = getpass.getpass(f"{label}: ")
    if not value:
        raise PromptUnavailable(f"no {label} entered")
    return value


def ask_new_secret(label: str, *, min_length: int = 12, attempts: int = 3,
                   no_input: bool = False, hint: str = "") -> str:
    """Prompt for a new secret twice and insist they match."""
    if not can_prompt(no_input):
        raise PromptUnavailable(f"cannot prompt for {label} here; {hint}".rstrip("; "))
    for remaining in range(attempts - 1, -1, -1):
        first = getpass.getpass(f"New {label}: ")
        if len(first) < min_length:
            print(f"{label} must be at least {min_length} characters", file=sys.stderr)
        elif first != getpass.getpass(f"Repeat {label}: "):
            print(f"{label}s do not match", file=sys.stderr)
        else:
            return first
        if remaining:
            print(f"try again ({remaining} attempt{'s' if remaining > 1 else ''} left)", file=sys.stderr)
    raise PromptUnavailable(f"no {label} set after {attempts} attempts")

getpass.getpass() does the important low-level work: it turns off terminal echo, reads a line, and restores echo even if the user presses Ctrl+C. On POSIX it reads from the controlling terminal (/dev/tty) rather than stdin, and writes its prompt there too — so it still works when stdin or stdout are redirected, and the prompt never pollutes a pipe. On Windows it uses the console directly.

The command layer wires the helpers to flags:

# src/mytool/cli.py
import sys

import typer

from mytool.prompts import PromptUnavailable, ask_new_secret

app = typer.Typer()


@app.callback()
def main() -> None:
    """Local vault."""


@app.command()
def init(
    password_stdin: bool = typer.Option(False, "--password-stdin", help="Read the password from stdin."),
    no_input: bool = typer.Option(False, "--no-input", help="Never prompt; fail instead."),
) -> None:
    """Create a new encrypted vault."""
    if password_stdin:
        password = sys.stdin.readline().rstrip("\r\n")
        if len(password) < 12:
            typer.echo("error: password from stdin must be at least 12 characters", err=True)
            raise typer.Exit(2)
    else:
        try:
            password = ask_new_secret("vault password", no_input=no_input,
                                      hint="use --password-stdin or set MYTOOL_VAULT_PASSWORD_FILE")
        except PromptUnavailable as exc:
            typer.echo(f"error: {exc}", err=True)
            raise typer.Exit(2)
    create_vault(password)
    typer.echo("vault created", err=True)


def create_vault(password: str) -> None:
    ...  # derive a key with hashlib.scrypt and write the vault


if __name__ == "__main__":
    app()

Typer and Click also offer the one-liner typer.prompt("Password", hide_input=True, confirmation_prompt=True), which disables echo and asks twice. It is a good choice for simple cases; the hand-written helper above adds the isatty guard, a length policy and a bounded number of attempts, which the built-in prompt does not. Whichever you use, route the prompt to stderr (err=True) when you are not using getpass, so it never lands in captured stdout.

A password prompt done well Terminal output of a secure prompt: no characters echoed, a confirmation prompt, a mismatch message and a retry. A password prompt done well bash $ mytool vault init New vault password: Repeat password: passwords do not match, try again (2 attempts left) New vault password: Repeat password: vault created at ~/.local/share/mytool/vault.db Nothing is echoed, and a mismatch costs one retry rather than a restart.

UX considerations

Secure prompt rules Rules for prompting for secrets in a command line tool: prompt on stderr, no echo, confirm new secrets, and never offer a secret as a default. Secure prompt rules Do Prompt via getpass or hide_input=True Write prompts to stderr, not stdout Confirm when setting a new secret Offer --password-stdin for automation Do not Show a stored secret as the default Echo the secret back in a confirmation Accept the secret as a plain argument Retry forever on a mismatch getpass reads from the controlling terminal, so it works even when stdin is redirected.
  • Confirm new secrets, not existing ones. A typo in a new vault password locks the user out; asking twice prevents it. Asking twice to unlock is just friction.
  • Validate before confirming. Check length or strength on the first entry, so the user does not type a too-short password twice before being told.
  • Bound the attempts. Three mismatches is a strong signal of a problem; exit with a clear message instead of looping.
  • Never show a stored secret as a default. Prompts like Token [ghp_abc...]: put the secret on screen. If a value exists, say "press Enter to keep the current token" without showing it.
  • Always offer a non-interactive path. --password-stdin for pipes from password managers; a _FILE environment variable for containers. Mention them in the error when prompting is impossible.
  • Do not print the secret back. "Password set" is the confirmation users need; "Password set to hunter2" is a leak waiting for a screen share.
  • Handle Ctrl+C at the prompt. getpass restores echo; your top-level handler should print a short "cancelled" and exit 130 rather than a traceback, as in handling KeyboardInterrupt cleanly.

Testing the behaviour

Prompts are awkward to test through a real terminal, so replace getpass.getpass with a scripted fake and control isatty directly. Everything else runs normally:

# tests/test_prompts.py
import io

import pytest

from mytool import prompts
from mytool.prompts import PromptUnavailable, ask_new_secret, ask_secret


class FakeTTY(io.StringIO):
    def isatty(self) -> bool:
        return True


@pytest.fixture
def tty(monkeypatch):
    monkeypatch.setattr(prompts.sys, "stdin", FakeTTY())
    monkeypatch.delenv("MYTOOL_NO_INPUT", raising=False)


def script(monkeypatch, *answers):
    it = iter(answers)
    monkeypatch.setattr(prompts.getpass, "getpass", lambda prompt="": next(it))


def test_new_secret_matching(tty, monkeypatch):
    script(monkeypatch, "correct horse battery", "correct horse battery")
    assert ask_new_secret("password") == "correct horse battery"


def test_mismatch_then_success(tty, monkeypatch, capsys):
    script(monkeypatch, "correct horse battery", "typo horse battery",
           "correct horse battery", "correct horse battery")
    assert ask_new_secret("password") == "correct horse battery"
    assert "do not match" in capsys.readouterr().err


def test_gives_up_after_attempts(tty, monkeypatch):
    script(monkeypatch, *["short"] * 3)
    with pytest.raises(PromptUnavailable, match="3 attempts"):
        ask_new_secret("password")


def test_refuses_without_terminal(monkeypatch):
    monkeypatch.setattr(prompts.sys, "stdin", io.StringIO())   # isatty() is False
    with pytest.raises(PromptUnavailable, match="--password-stdin"):
        ask_secret("password", hint="use --password-stdin")


def test_no_input_env(tty, monkeypatch):
    monkeypatch.setenv("MYTOOL_NO_INPUT", "1")
    with pytest.raises(PromptUnavailable):
        ask_secret("password")

The refusal tests matter most: they guarantee the tool fails fast in CI rather than hanging. For end-to-end tests of the --password-stdin path, CliRunner.invoke(app, ["init", "--password-stdin"], input="a-long-password\n") feeds stdin directly, as covered in testing interactive prompts and stdin.

Conclusion

A secure prompt is short to write and easy to get subtly wrong. Only prompt when a person is present and has not opted out; use getpass or hide_input so nothing is echoed and nothing reaches stdout; confirm new secrets and bound the retries; and always provide --password-stdin or a file variable for automation. With the prompt logic in a small module and getpass patched in tests, every branch — including "never hang in CI" — is covered.

Frequently asked questions

Why does getpass print a GetPassWarning in my IDE?

Some IDE consoles are not real terminals, so getpass cannot disable echo and falls back to reading visibly, with a warning. Run the tool in a real terminal, or use the IDE's run configuration option to emulate one.

Should I show asterisks as the user types?

Neither getpass nor Click does, and showing them leaks the length. If you want visible feedback, prompt_toolkit or questionary can mask input with a character — see building interactive prompts and menus.

How do I read a password from stdin without the trailing newline?

sys.stdin.readline().rstrip("\r\n") — strip only line endings so passwords with trailing spaces survive. Reading with .strip() silently changes such passwords.

When in the command should the prompt happen?

After every cheap check has passed, and before any slow or irreversible work starts. Validate arguments, confirm files exist and check that you can reach the server first — being asked for a password and then told the target directory does not exist is irritating. But collect the secret before a long download or a multi-step change, so the user is not called back to the keyboard ten minutes in, and so a mistyped password cannot leave the work half-done.

Can one prompt cover several commands in a row?

Not by caching the password yourself. If users run many commands that need the same secret, that is a sign it belongs in the keychain or an agent process, the way ssh-agent and gpg-agent hold keys for a session. A short-lived session token obtained with the password and stored like any other token achieves the same effect without the password ever being written anywhere.

Is it safe to keep the password in a variable?

For the lifetime of one command, yes. Pass it straight to the key-derivation or authentication call and let it go out of scope; avoid storing it on long-lived objects or in logs, and never cache it to disk.