A password manager needs to do more than hide passwords in a file. It must encrypt them correctly, detect damaged data, save changes safely, and provide a recovery path through backups.
In this tutorial, you’ll build a practical command-line password manager that runs entirely on your computer. It stores usernames, passwords, URLs, and notes inside one encrypted vault. Your master passphrase unlocks that vault; it is never saved.
What you’ll build
Your password manager will support:
· Creating an encrypted vault. · Adding, listing, retrieving, updating, and deleting entries. · Generating strong random passwords. · Changing the master passphrase. · Creating encrypted backups. · Running inside Docker with networking disabled.
The application uses scrypt to derive an encryption key and AES-256-GCM to encrypt the vault and authenticate its contents.
Security scope: This design protects a locked vault copied from your disk. Malware, keyloggers, and a compromised computer can still capture secrets when you use them. The application has not received an independent security audit; develop and test it with disposable credentials.
Prerequisites
You’ll need:
· Python 3.12 or newer. · Basic familiarity with Python and terminal commands. · Linux, macOS, or Windows through WSL2. · At least 256 MiB of available memory for the application. · Docker and Docker Compose, if you want the container setup.
Use a private directory on a local filesystem. Network shares and synchronized folders can introduce different locking and file-replacement behavior.
Dependency installation and image building require internet access. The application itself makes no network requests.
Project structure
local-password-manager/
├── vault.py
├── requirements.txt
├── .env.example
├── .env
├── .gitignore
├── .dockerignore
├── Dockerfile
├── compose.yaml
└── data/
├── vault.json
├── vault.json.lock
└── backups/
The application creates the vault directory and lock file automatically.
Step 1: Create the project
mkdir local-password-manager
cd local-password-manager
python3 -m venv .venv
source .venv/bin/activate
Create requirements.txt:
cryptography>=46,<51
filelock>=3.20,<4
python-dotenv>=1.1,<2
Install the dependencies:
python -m pip install -r requirements.txt
Each dependency has a specific purpose:
Dependency Purpose cryptography Provides scrypt and authenticated encryption filelock Prevents concurrent commands from overwriting changes python-dotenv Loads local configuration from .env
The version ranges allow compatible updates. For a reproducible release, record exact versions after validating your chosen environment.
Step 2: Configure environment variables
Create .env.example:
VAULT_PATH=./data/vault.json
PASSWORD_LENGTH=24
Copy it:
cp .env.example .env
chmod 600 .env
VAULT_PATH selects the encrypted file. Relative paths resolve from your current working directory, so run the tutorial commands from the project directory.
PASSWORD_LENGTH controls generated passwords and must be between 16 and 256.
Keep the master passphrase and entry passwords out of .env. The application reads those through hidden terminal prompts.
Create .gitignore:
.env
.venv/
__pycache__/
data/
backups/
*.vault-backup.json
Create .dockerignore:
.env
.venv/
__pycache__/
data/
backups/
*.vault-backup.json
.git/
This keeps configuration, vault files, and backups out of Git and the Docker build context.
Step 3: Implement the encrypted vault
Create vault.py with the following complete implementation:
import argparse
import base64
import getpass
import json
import os
from pathlib import Path
import secrets
import string
import sys
import tempfile
import warnings
from datetime import datetime, timezone
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from dotenv import load_dotenv
from filelock import FileLock, Timeout
KDF_NAME = "scrypt-n131072-r8-p1"
MAX_BYTES = 5 * 1024 * 1024
FIELDS = {"username", "password", "url", "notes", "updated_at"}
class VaultError(Exception):
pass
def canonical(value):
return json.dumps(
value, sort_keys=True, separators=(",", ":")
).encode()
def encode(value):
return base64.b64encode(value).decode("ascii")
def decode(value):
if not isinstance(value, str):
raise VaultError("Invalid vault encoding.")
return base64.b64decode(value, validate=True)
def hidden(prompt):
# Refuse getpass's fallback to visible input without a terminal.
with warnings.catch_warnings():
warnings.simplefilter("error", getpass.GetPassWarning)
return getpass.getpass(prompt)
def new_master():
password = hidden("New master passphrase: ")
if len(password) < 16:
raise VaultError(
"Use at least 16 characters; prefer 5–6 random words."
)
if password != hidden("Confirm master passphrase: "):
raise VaultError("Passphrases do not match.")
return password
def derive_key(password, salt):
return Scrypt(
salt=salt, length=32, n=2**17, r=8, p=1
).derive(password.encode("utf-8"))
def generate_password(length):
alphabet = string.ascii_letters + string.digits + "-_"
return "".join(secrets.choice(alphabet) for _ in range(length))
def validate_data(data):
if not isinstance(data, dict) or set(data) != {"entries"}:
raise VaultError("Invalid decrypted vault structure.")
if not isinstance(data["entries"], dict):
raise VaultError("Invalid entries structure.")
for name, entry in data["entries"].items():
if (
not isinstance(name, str)
or not name.strip()
or len(name) > 128
):
raise VaultError("Invalid entry name.")
if not isinstance(entry, dict) or set(entry) != FIELDS:
raise VaultError("Invalid entry structure.")
if any(
not isinstance(value, str) for value in entry.values()
):
raise VaultError("Entry fields must be strings.")
if not entry["password"]:
raise VaultError("Entry password cannot be empty.")
return data
def reject_symlink(path):
if path.is_symlink():
raise VaultError(f"Refusing symbolic link: {path}")
def private_directory(directory):
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
reject_symlink(directory)
if os.name == "posix":
info = directory.stat()
if info.st_uid != os.getuid() or info.st_mode & 0o077:
raise VaultError(
"Directory must be owned by you and mode 700: "
f"{directory}"
)
def read_bytes(path):
reject_symlink(path)
if not path.is_file():
raise VaultError("Vault must be a regular file.")
with path.open("rb") as stream:
raw = stream.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
raise VaultError("Vault exceeds the 5 MiB limit.")
return raw
def unlock(path):
outer = json.loads(read_bytes(path))
if (
not isinstance(outer, dict)
or set(outer) != {"header", "ciphertext"}
):
raise VaultError("Invalid vault envelope.")
header = outer["header"]
if (
not isinstance(header, dict)
or set(header) != {"version", "kdf", "salt", "nonce"}
):
raise VaultError("Invalid vault header.")
if type(header["version"]) is not int or header["version"] != 1:
raise VaultError("Unsupported vault version.")
if header["kdf"] != KDF_NAME:
raise VaultError("Unsupported KDF parameters.")
salt = decode(header["salt"])
nonce = decode(header["nonce"])
ciphertext = decode(outer["ciphertext"])
if len(salt) != 16 or len(nonce) != 12 or len(ciphertext) < 16:
raise VaultError("Invalid cryptographic field lengths.")
key = derive_key(hidden("Master passphrase: "), salt)
plaintext = AESGCM(key).decrypt(
nonce, ciphertext, canonical(header)
)
return validate_data(json.loads(plaintext)), key, salt
def sync_directory(directory):
if os.name == "posix":
descriptor = os.open(directory, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def save(path, data, key, salt):
validate_data(data)
nonce = secrets.token_bytes(12)
header = {
"version": 1,
"kdf": KDF_NAME,
"salt": encode(salt),
"nonce": encode(nonce),
}
ciphertext = AESGCM(key).encrypt(
nonce, canonical(data), canonical(header)
)
raw = canonical({
"header": header,
"ciphertext": encode(ciphertext),
})
if len(raw) > MAX_BYTES:
raise VaultError("Vault would exceed the 5 MiB limit.")
descriptor, filename = tempfile.mkstemp(
prefix=".vault-", dir=path.parent
)
temporary = Path(filename)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
reject_symlink(path)
os.replace(temporary, path)
sync_directory(path.parent)
finally:
temporary.unlink(missing_ok=True)
def edit_entry(previous, generate, length):
result = dict(previous or {})
for field in ("username", "url", "notes"):
current = result.get(field, "")
prompt = f"{field.title()}"
if previous:
prompt += " (Enter keeps, '-' clears)"
if field != "notes":
prompt += f" [{current}]"
prompt += ": "
value = hidden(prompt) if field == "notes" else input(prompt)
if previous and value == "":
value = current
elif previous and value == "-":
value = ""
result[field] = value
if generate:
result["password"] = generate_password(length)
else:
prompt = (
"Password (Enter keeps): "
if previous
else "Password (blank generates): "
)
value = hidden(prompt)
result["password"] = value or (
previous["password"]
if previous
else generate_password(length)
)
result["updated_at"] = datetime.now(timezone.utc).isoformat(
timespec="seconds"
)
return result
def backup(path, destination):
destination = destination.expanduser().absolute()
reject_symlink(destination)
private_directory(destination.parent)
raw = read_bytes(path)
# O_EXCL prevents overwriting a backup or the active vault.
descriptor = os.open(
destination,
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o600,
)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
destination.unlink(missing_ok=True)
raise
sync_directory(destination.parent)
print(f"Encrypted backup created: {destination}")
def parser():
result = argparse.ArgumentParser(
description="Local encrypted password vault"
)
commands = result.add_subparsers(
dest="command", required=True
)
for command in ("init", "list", "passwd", "generate"):
commands.add_parser(command)
for command in ("add", "update", "get", "delete"):
item = commands.add_parser(command)
item.add_argument("name")
if command in {"add", "update"}:
item.add_argument("--generate", action="store_true")
if command == "get":
item.add_argument("--show", action="store_true")
commands.add_parser("backup").add_argument(
"destination", type=Path
)
return result
def run():
load_dotenv(Path(__file__).with_name(".env"), override=False)
args = parser().parse_args()
length = int(os.getenv("PASSWORD_LENGTH", "24"))
if not 16 <= length <= 256:
raise VaultError(
"PASSWORD_LENGTH must be between 16 and 256."
)
if args.command == "generate":
print(generate_password(length))
return
path = Path(
os.getenv("VAULT_PATH", "./data/vault.json")
).expanduser().absolute()
private_directory(path.parent)
lock_path = path.with_name(path.name + ".lock")
reject_symlink(path)
reject_symlink(lock_path)
with FileLock(str(lock_path), timeout=10, mode=0o600):
if args.command == "init":
if path.exists():
raise VaultError(
"Vault already exists; initialization "
"will not overwrite it."
)
salt = secrets.token_bytes(16)
save(
path,
{"entries": {}},
derive_key(new_master(), salt),
salt,
)
print(
"Vault initialized. "
"Keep your master passphrase safe."
)
return
if not path.exists():
raise VaultError(
"Vault not found. "
"Run init first, or check VAULT_PATH."
)
if args.command == "backup":
backup(path, args.destination)
return
data, key, salt = unlock(path)
entries = data["entries"]
if args.command == "list":
for name in sorted(entries):
print(name)
if not entries:
print("Vault is empty.")
return
if args.command == "passwd":
salt = secrets.token_bytes(16)
key = derive_key(new_master(), salt)
save(path, data, key, salt)
print(
"Master passphrase changed. "
"Existing backups keep their old passphrase."
)
return
name = args.name.strip()
if not name or len(name) > 128:
raise VaultError(
"Entry names must contain 1–128 characters."
)
if args.command == "add":
if name in entries:
raise VaultError("Entry exists. Use update.")
entries[name] = edit_entry(
None, args.generate, length
)
else:
if name not in entries:
raise VaultError(
"Entry not found; names are case-sensitive."
)
if args.command == "get":
entry = entries[name]
print(f"Name: {name}")
for field in ("username", "url", "updated_at"):
print(f"{field}: {entry[field]}")
if args.show:
print(f"password: {entry['password']}")
print(f"notes: {entry['notes']}")
else:
print(
"Password and notes hidden. "
"Use --show to reveal."
)
return
if args.command == "update":
entries[name] = edit_entry(
entries[name], args.generate, length
)
elif args.command == "delete":
if input(f"Type {name!r} to delete it: ") != name:
raise VaultError("Deletion cancelled.")
del entries[name]
save(path, data, key, salt)
print("Vault saved.")
if __name__ == "__main__":
try:
run()
except InvalidTag:
print(
"Error: wrong master passphrase or damaged vault.",
file=sys.stderr,
)
sys.exit(1)
except Timeout:
print(
"Error: vault is busy. "
"Close the other vault command and retry.",
file=sys.stderr,
)
sys.exit(1)
except getpass.GetPassWarning:
print(
"Error: use an interactive terminal; "
"secret input cannot be hidden.",
file=sys.stderr,
)
sys.exit(1)
except RecursionError:
print(
"Error: invalid vault JSON nesting.",
file=sys.stderr,
)
sys.exit(1)
except (VaultError, OSError, ValueError) as error:
print(f"Error: {error}", file=sys.stderr)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
print("\nCancelled.", file=sys.stderr)
sys.exit(1)
Understand the encryption flow
The vault has two layers:
Unencrypted envelope
├── version
├── KDF identifier
├── random salt
├── random nonce
└── ciphertext
└── encrypted entries, including their names and metadata
The salt and nonce are public values. They do not reveal the master passphrase.
When unlocking, the application:
- Checks the file size and validates the envelope.
- Derives a 32-byte key from the master passphrase and salt.
- Authenticates and decrypts the ciphertext.
- Validates the decrypted structure before using it.
The header is authenticated through AES-GCM’s additional authenticated data. Changing the nonce, salt, or supported header values causes validation or decryption to fail.
Every save uses a fresh nonce. Reusing a nonce with the same AES-GCM key can compromise encryption.
Understand safe writes
The application writes encrypted bytes to a temporary file in the vault directory, flushes them, and replaces the vault with os.replace().
This avoids leaving a half-written active vault after an interrupted write. The temporary file starts with restrictive permissions, and the lock covers the complete read-modify-write operation.
The private directory remains the filesystem security boundary. These checks do not defend against a compromised account controlling your files.
Step 4: Create and use the vault
Initialize it
python vault.py init
Choose a unique master passphrase, preferably five or six randomly selected words. The application requires at least 16 characters, but length alone does not guarantee strength.
There is no master-passphrase recovery. Losing it means losing access to the encrypted contents.
Initialization refuses to overwrite an existing vault.
Add an entry with a generated password
python vault.py add github --generate
Enter your master passphrase, then provide:
Username: example-user
Url: https://github.com
Notes:
Notes use hidden input, so typed characters do not appear.
The generated password is saved without being printed. To store an existing password, omit --generate:
python vault.py add email
A blank password during add generates one automatically.
List and retrieve entries
python vault.py list
python vault.py get github
get displays the username, URL, and update timestamp. Reveal the password and notes explicitly:
python vault.py get github --show
Entry names are case-sensitive.
Revealed secrets remain in terminal scrollback and can enter output files if redirected. Entry names also appear in shell history, so use neutral labels when that matters.
Update an entry
python vault.py update github
During updates:
· Enter preserves the existing value. · - clears the username, URL, or notes. · A blank password preserves the existing password.
Generate a replacement password:
python vault.py update github --generate
This changes the stored value. Update the password at the corresponding service separately.
Generate a standalone password
python vault.py generate
This command prints a random password without opening the vault. The default alphabet contains letters, digits, -, and _; adapt it if a service has different requirements.
Change the master passphrase
python vault.py passwd
Enter the current passphrase, then enter and confirm the new one.
The application creates a fresh salt, derives a new key, and encrypts the current vault again. Existing backups retain their original passphrase.
Delete an entry
python vault.py delete github
Deletion requires typing the entry name after unlocking.
Deleting an entry does not remove it from older backups.
Step 5: Create and verify encrypted backups
Create a backup using a new filename:
python vault.py backup \
./data/backups/vault-2026-10-14.vault-backup.json
The backup command copies the encrypted bytes under the vault lock. It does not need the master passphrase because it never decrypts anything.
Existing destinations are rejected.
Verify that the backup unlocks:
VAULT_PATH=./data/backups/vault-2026-10-14.vault-backup.json \
python vault.py list
Use the passphrase that was active when the backup was created.
A backup on the same disk helps recover from accidental changes, but not disk failure. Keep another encrypted copy on a private external drive.
Recover without overwriting the current vault
Copy a verified backup to a separate recovery file:
cp -p \
data/backups/vault-2026-10-14.vault-backup.json \
data/recovered.vault-backup.json
VAULT_PATH=./data/recovered.vault-backup.json \
python vault.py list
Choose an unused recovery filename. If the recovered copy unlocks successfully, set this in .env to use it:
VAULT_PATH=./data/recovered.vault-backup.json
Your original vault and backup remain available.
Step 6: Run it with Docker Compose
Docker provides consistent packaging. The container will run with no network access and persist vault data through a bind mount.
Create Dockerfile:
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN groupadd --gid 10001 vault \
&& useradd --uid 10001 --gid 10001 --no-create-home vault
COPY --chmod=644 vault.py .
USER 10001:10001
ENTRYPOINT ["python", "/app/vault.py"]
Explicit source-file permissions allow the runtime user to read the application even when the host file has restrictive permissions.
Create compose.yaml:
services:
vault:
build: .
user: "${LOCAL_UID:?Set LOCAL_UID}:${LOCAL_GID:?Set LOCAL_GID}"
env_file: .env
environment:
VAULT_PATH: /data/vault.json
volumes:
- ./data:/data
network_mode: none
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
stdin_open: true
tty: true
Prepare the directory and export your user IDs from an ordinary host account:
mkdir -p data
chmod 700 data
export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
docker compose build
The user mapping gives the container access to your private directory without making its files root-owned. Export these variables again when starting a new terminal session.
For a new vault:
docker compose run --rm vault init
If you already initialized the default host vault, use it directly:
docker compose run --rm vault list
Other commands work the same way:
docker compose run --rm vault add email --generate
docker compose run --rm vault get email --show
docker compose run --rm vault update email
Create backups inside the mounted directory:
docker compose run --rm vault backup \
/data/backups/docker-backup.vault-backup.json
Only /data persists between these containers.
Compose overrides VAULT_PATH to /data/vault.json. To verify another file in the mount:
docker compose run --rm \
-e VAULT_PATH=/data/backups/docker-backup.vault-backup.json \
vault list
The container uses an interactive terminal for hidden prompts, a read-only root filesystem, and a writable temporary filesystem. Docker does not protect secrets from a compromised host.
Testing the project
Use disposable entries for these checks.
Check the basic workflow
python vault.py add test-entry --generate
python vault.py list
python vault.py get test-entry
python vault.py get test-entry --show
python vault.py update test-entry --generate
Confirm that:
· The entry appears in list. · Ordinary retrieval hides passwords and notes. · --show reveals the generated password. · Updating with --generate changes that password.
Check an incorrect master passphrase
python vault.py get test-entry
Enter an incorrect passphrase. Expected output:
Error: wrong master passphrase or damaged vault.
The command exits with status 1 and does not save changes.
Check the on-disk format
python - <<'PY'
import json
from pathlib import Path
envelope = json.loads(Path("data/vault.json").read_bytes())
print("Top-level fields:", sorted(envelope))
print("KDF:", envelope["header"]["kdf"])
print("Encrypted payload present:", bool(envelope["ciphertext"]))
PY
With the default path, expected output is:
Top-level fields: ['ciphertext', 'header']
KDF: scrypt-n131072-r8-p1
Encrypted payload present: True
Base64 is only the encoding used to store binary values in JSON. AES-GCM provides the encryption.
Check tamper detection on a disposable copy
Create a separate encrypted copy:
python vault.py backup ./data/tamper-test.vault-backup.json
Change one ciphertext byte:
python - <<'PY'
import base64
import json
from pathlib import Path
path = Path("data/tamper-test.vault-backup.json")
envelope = json.loads(path.read_bytes())
ciphertext = bytearray(base64.b64decode(envelope["ciphertext"]))
ciphertext[0] ^= 1
envelope["ciphertext"] = base64.b64encode(ciphertext).decode("ascii")
path.write_text(json.dumps(envelope))
PY
Try unlocking the modified copy:
VAULT_PATH=./data/tamper-test.vault-backup.json \
python vault.py list
Even with the correct master passphrase, authentication should fail. The original vault remains untouched.
Authenticated encryption detects modified contents. It cannot detect someone replacing the vault with an older, valid encrypted copy.
Design decisions
Decision Reason Local command-line interface Keeps operation offline and avoids server sessions Encrypt the entire entry collection Protects labels, usernames, URLs, passwords, and notes scrypt with n=2¹⁷, r=8, p=1 Makes password guessing consume time and roughly 128 MiB of working memory Random 16-byte salt Produces different keys for vaults using the same passphrase AES-256-GCM Provides encryption and authentication together Fresh 12-byte nonce per save Avoids intentional nonce reuse under the same key Authenticated header Protects the supported encryption metadata from alteration Fixed KDF settings Prevents a file from requesting arbitrary derivation costs Hidden secret prompts Keeps passwords out of command arguments and environment files Whole-operation file lock Prevents concurrent read-modify-write conflicts Atomic replacement and filesystem flushing Reduces partial-write and crash-durability risks 5 MiB file limit Bounds normal vault input and keeps the project focused
Each command unlocks independently. There is no persistent unlocked session, although Python cannot guarantee that every password or key copy is erased from memory when a command finishes.
Common issues and troubleshooting
Issue Cause and fix Vault not found Check VAULT_PATH and your working directory. Run init only when creating a new vault. Directory ownership or mode error Ensure the directory belongs to your account, then use chmod 700 data. Backup directories need the same protection. Wrong passphrase or damaged vault Check keyboard layout and the backup’s original passphrase. Preserve the file and try a verified backup. Vault is busy Another command holds the lock, possibly while waiting for input. Finish or cancel it and retry. Existing backup destination Choose a new filename. Backups intentionally refuse to overwrite files. Secret input cannot be hidden Run from an interactive terminal. For Docker, retain the Compose terminal settings. Slow unlock or memory error scrypt needs roughly 128 MiB plus application overhead. Give the process more memory; avoid changing parameters for an existing vault. Docker permission error Export your current UID/GID and ensure the host data directory is owned by your account. File or directory flush error Use a filesystem supporting these operations. Replacement may already have occurred; inspect the vault before retrying.
The .lock file can remain after a command exits. Its presence alone does not mean the vault is locked; the operating system manages the active lock.
What you learned
You built a password manager that combines encryption with practical storage safeguards.
You learned how to:
· Derive an encryption key from a passphrase. · Authenticate both encrypted contents and public header values. · Keep sensitive input out of arguments and configuration. · Save encrypted files through locking and atomic replacement. · Rotate a master passphrase without exporting plaintext. · Verify encrypted backups and run the application offline in Docker.
Possible next steps
· Add encrypted search and entry renaming. · Support configurable password alphabets and site requirements. · Add import and export workflows with explicit handling of plaintext files. · Schedule encrypted backups to an external drive. · Package the CLI with a pyproject.toml entry point. · Expand automated checks for corruption, concurrent access, and interrupted writes. · Obtain an independent security review before relying on it for important credentials.