Storing passwords in plaintext—or even with fast hashes like MD5 or SHA-256—is a critical security vulnerability. Attackers who breach a database can crack
Storing passwords in plaintext—or even with fast hashes like MD5 or SHA-256—is a critical security vulnerability. Attackers who breach a database can crack billions of fast-hash passwords per second with commodity GPUs. Modern password storage requires a memory-hard algorithm that is deliberately slow and resistant to GPU/ASIC parallelization. Argon2 is the winner of the 2015 Password Hashing Competition and is the current OWASP-recommended default.
This script provides a small, dependency-light CLI that wraps argon2-cffi to hash a password (producing a self-describing encoded string you can store in a database) and to verify a plaintext password against a previously stored hash. It is designed for DevOps scripts, CI pipelines, and admin tooling where you need to generate or check a hash without writing boilerplate.
The script uses a two-command design driven by a mutually-exclusive argument group: --password triggers hashing, and --verify (combined with --password) triggers verification. Under the hood, argon2-cffi's PasswordHasher class handles salt generation, parameter selection, and encoding into the standard PHC string format ($argon2id$v=19$m=...,t=...,p=...$salt$hash). Because that encoded string already contains the salt and parameters, verification only needs the stored string and the candidate password—no separate salt column in your database.
Key design decisions:
0 success, 1 verification mismatch, 2 usage/config error) so the tool composes cleanly in shell scripts and CI.ps output.#!/usr/bin/env python3
"""
Password Hash Utility with argon2-cffi
Hash a password into a PHC-encoded Argon2id string, or verify a plaintext
password against a stored hash. Designed for CLI use in admin scripts and
CI pipelines.
Examples:
python3 hash_pass.py --password 'mysecret'
python3 hash_pass.py --password 'mysecret' --verify '$argon2id$v=19$...'
echo -n 'mysecret' | python3 hash_pass.py --stdin
"""
from __future__ import annotations
import argparse
import os
import sys
from typing import Optional
from argon2 import PasswordHasher
from argon2.exceptions import (
InvalidHashError,
VerificationError,
VerifyMismatchError,
)
from argon2.low_level import Type
# ---------------------------------------------------------------------------
# Configuration (overridable via environment variables)
# ---------------------------------------------------------------------------
# Defaults follow OWASP's recommended minimum for Argon2id (as of 2024):
# m = 19456 KiB (~19 MiB), t = 2 iterations, p = 1 lane.
# Increase m/t for higher-value secrets; tune to your hardware's latency budget.
ARGON2_TIME_COST = int(os.environ.get("ARGON2_TIME_COST", "2"))
ARGON2_MEMORY_COST = int(os.environ.get("ARGON2_MEMORY_COST", str(19 * 1024)))
ARGON2_PARALLELISM = int(os.environ.get("ARGON2_PARALLELISM", "1"))
ARGON2_HASH_LEN = int(os.environ.get("ARGON2_HASH_LEN", "32"))
ARGON2_SALT_LEN = int(os.environ.get("ARGON2_SALT_LEN", "16"))
ARGON2_TYPE_NAME = os.environ.get("ARGON2_TYPE", "id").lower()
# Map the human-readable type name to the low-level enum.
_TYPE_MAP = {
"id": Type.ID,
"i": Type.I,
"d": Type.D,
}
def _build_hasher() -> PasswordHasher:
"""Construct a PasswordHasher from the module-level configuration."""
try:
argon2_type = _TYPE_MAP[ARGON2_TYPE_NAME]
except KeyError as exc:
raise ValueError(
f"Invalid ARGON2_TYPE={ARGON2_TYPE_NAME!r}; expected one of "
f"{sorted(_TYPE_MAP)}"
) from exc
return PasswordHasher(
time_cost=ARGON2_TIME_COST,
memory_cost=ARGON2_MEMORY_COST,
parallelism=ARGON2_PARALLELISM,
hash_len=ARGON2_HASH_LEN,
salt_len=ARGON2_SALT_LEN,
type=argon2_type,
)
def hash_password(password: str) -> str:
"""Return a PHC-encoded Argon2 hash string for `password`."""
hasher = _build_hasher()
# The returned string embeds salt, params, and hash. Store this verbatim
# in a VARCHAR(255) (or TEXT) column. It is safe to log — it is one-way.
return hasher.hash(password)
def verify_password(stored_hash: str, candidate: str) -> bool:
"""
Return True if `candidate` matches `stored_hash`.
Raises InvalidHashError if `stored_hash` is not a valid Argon2 PHC string.
"""
hasher = _build_hasher()
try:
hasher.verify(stored_hash, candidate)
return True
except VerifyMismatchError:
return False
except VerificationError as exc:
# Covers malformed hashes and other low-level verification failures.
raise InvalidHashError(str(exc)) from exc
def _read_password(args: argparse.Namespace) -> str:
"""Resolve the plaintext password from CLI args or stdin."""
if args.password is not None:
return args.password
if args.stdin:
# Read the entire stream; strip a single trailing newline so
# `echo 'pw' | ...` works as expected without breaking passwords
# that legitimately end in whitespace-only content.
data = sys.stdin.read()
if data.endswith("\n"):
data = data[:-1]
if not data:
raise ValueError("No password received on stdin")
return data
raise ValueError("No password supplied (use --password or --stdin)")
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="hash_pass.py",
description="Hash or verify a password using Argon2id.",
)
parser.add_argument(
"--password",
"-p",
help="Plaintext password. WARNING: visible in shell history and ps.",
default=None,
)
parser.add_argument(
"--stdin",
action="store_true",
help="Read the plaintext password from stdin (safer than --password).",
)
parser.add_argument(
"--verify",
"-v",
metavar="HASH",
help="Verify --password/--stdin against this stored Argon2 hash.",
default=None,
)
parser.add_argument(
"--quiet",
"-q",
action="store_true",
help="Suppress non-essential output (useful in scripts).",
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
# --- Resolve the plaintext password -----------------------------------
try:
password = _read_password(args)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
# --- Verify mode -------------------------------------------------------
if args.verify is not None:
try:
ok = verify_password(args.verify, password)
except InvalidHashError as exc:
print(f"error: invalid stored hash: {exc}", file=sys.stderr)
return 2
if ok:
if not args.quiet:
print("OK: password matches")
return 0
if not args.quiet:
print("FAIL: password does not match", file=sys.stderr)
return 1
# --- Hash mode ---------------------------------------------------------
try:
encoded = hash_password(password)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
print(encoded)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(130)
All tuning is done through environment variables, so you can adjust parameters per deployment without editing the script.
| Variable | Default | Purpose |
|---|---|---|
ARGON2_TIME_COST |
2 |
Number of iterations. Higher = slower to hash and crack. |
ARGON2_MEMORY_COST |
19456 |
Memory in KiB. This is the primary GPU-resistance knob. |
ARGON2_PARALLELISM |
1 |
Lanes (threads). Match to CPU cores for server workloads. |
ARGON2_HASH_LEN |
32 |
Output hash length in bytes. 32 is standard. |
ARGON2_SALT_LEN |
16 |
Random salt length in bytes. Never lower than 16. |
ARGON2_TYPE |
id |
One of id, i, d. Keep id unless you have a reason. |
Example: raise the memory cost to 64 MiB on a beefy auth server:
ARGON2_MEMORY_COST=65536 ARGON2_TIME_COST=3 python3 hash_pass.py --stdin
1. Hash a password (interactive, avoids shell history):
$ read -s -p "Password: " PW; echo
Password:
$ echo -n "$PW" | python3 hash_pass.py --stdin
$argon2id$v=19$m=19456,t=2,p=1$YjA1M2E...$3f9c1d...
2. Verify against a stored hash:
$ python3 hash_pass.py --password 'mysecret' --verify '$argon2id$v=19$m=19456,t=2,p=1$YjA1M2E...$3f9c1d...'
OK: password matches
$ echo $?
0
3. Wrong password (exit code 1 for scripting):
$ python3 hash_pass.py --password 'wrong' --verify '$argon2id$v=19$m=19456,t=2,p=1$YjA1M2E...$3f9c1d...'
FAIL: password does not match
$ echo $?
1
4. Use in a shell conditional:
if python3 hash_pass.py --quiet --password "$PW" --verify "$STORED"; then
echo "Authenticated"
else
echo "Access denied" >&2
exit 1
fi
| Situation | Behavior |
|---|---|
No --password and no --stdin |
Prints error: No password supplied... and exits 2. |
| Empty stdin | Prints error: No password received on stdin and exits 2. |
| Malformed stored hash (verify mode) | Prints error: invalid stored hash: ... and exits 2. |
| Password mismatch | Prints FAIL: password does not match to stderr and exits 1. |
Invalid ARGON2_TYPE value |
Raises ValueError, caught and reported, exit 2. |
Ctrl-C |
Clean exit code 130 (standard for SIGINT). |
The script never writes the plaintext password to stdout; only the hash is printed. Verification failures go to stderr so they don't pollute piped output.
username,password on stdin and emit username,hash so you can seed a user table in one pass.hasher.check_needs_rehash(stored_hash) and if True, emit a fresh hash so old parameters are upgraded transparently.--password with a lookup against AWS Secrets Manager, HashiCorp Vault, or the OS keyring so plaintext never touches argv.--json flag that emits {"algorithm": "argon2id", "hash": "...", "params": {...}} for consumption by other tools.