Why Passwords Get Stolen

Build and break three password systems to learn why Argon2id is the standard.

Introduction

30 Second Summary

Every time a company announces a data breach, millions of passwords end up for sale online. The reason is almost always the same: someone stored passwords in a way that lets attackers read them.

In this project, you will build a password storage system three different ways, deliberately break the first two yourself, and rebuild it correctly using the industry standard that companies like GitHub and Stripe rely on.

What You'll Build

You run attacker scripts against your own password vaults, watch them crack encrypted and hashed passwords in seconds, then prove that your final Argon2id implementation makes those same attacks take years.

By the end of this project, you'll have:

  • A working encrypted vault you can register users in, then break yourself by stealing the key and reading every password in plaintext.
  • A dictionary attack script that cracks SHA-256 hashed passwords in milliseconds, proving why fast hashing algorithms are dangerously weak for password storage.
  • A production-ready password system using Argon2id with automatic salting, plus a benchmark proving it reduces attacker throughput from millions of guesses per second to single digits.
  • Secret Mission: Add password breach detection and automatic rehashing when security parameters change.

Are there any prerequisites?

You should be comfortable with basic Python concepts: variables, functions, dictionaries, and file I/O. No prior security knowledge is needed.

Before We Start

Before we touch any code, let's lock in what you're about to build and why it matters. This project is unusual because you will deliberately build insecure systems, break them yourself, and only then build the correct solution.

Set Up Your Project Environment

This project needs two Python libraries to demonstrate password storage done wrong and done right. The cryptography library provides encryption (the wrong approach), and argon2-cffi provides Argon2id hashing (the right approach).

In this step, you'll create a dedicated project folder, set up an isolated Python environment, and install both libraries so they're ready for the experiments ahead.

In this step, get ready to:
  • Create a password-security-lab project directory with a virtual environment.
  • Install the cryptography and argon2-cffi libraries.
  • Verify both libraries import successfully.
Create the project directory and virtual environment

A virtual environment keeps your project's libraries isolated from the rest of your system. The libraries you install here won't interfere with anything else on your computer.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir password-security-lab
cd password-security-lab
  • Create a virtual environment inside your project folder by running:
python -m venv venv

What does this command do?

python -m venv venv tells Python to create a virtual environment in a folder called venv. This folder contains its own copy of Python and a place to install libraries without affecting your system.

  • Activate the virtual environment so that any libraries you install go into this project only:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you ran the activation command for your operating system. On Windows, if you see an error about execution policies, run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser first, then retry the activation command.

On macOS, if you see "command not found: python", try using python3 -m venv venv instead, then activate again.

help me activate my Python virtual environment

Install dependencies and verify

With the virtual environment active, you can now install the two libraries the project needs. You'll pin them to specific versions in a requirements.txt file so the project is reproducible.

  • Create a requirements.txt file that lists both libraries by running:

macOS

printf "cryptography==49.0.0\nargon2-cffi==25.1.0\n" > requirements.txt

Windows

"cryptography==49.0.0`nargon2-cffi==25.1.0" | Out-File requirements.txt
  • Confirm the file was created correctly by running:
cat requirements.txt

You should see the two pinned library versions printed:

cryptography==49.0.0
argon2-cffi==25.1.0

Why pin versions?

Pinning versions (==49.0.0) ensures everyone running this project gets the exact same library versions. Without pinning, a future update could change behavior and break your code.

  • Install both libraries by running:
pip install -r requirements.txt

You'll see pip downloading and installing packages. The output ends with a line confirming both packages were installed successfully.

Seeing an installation error?

Make sure (venv) is visible in your prompt before running pip install. If you see permission errors, your virtual environment is not active. Go back and run the activation command for your OS.

help me install cryptography and argon2-cffi in my virtual environment

Before you run the final check, what do you think will happen if both libraries installed correctly?

  • Verify everything is ready by running this command:
python -c "from cryptography.fernet import Fernet; from argon2 import PasswordHasher; print('Ready to break some passwords')"

You should see Ready to break some passwords printed in your terminal. This confirms both libraries are installed and importable.

Seeing a ModuleNotFoundError?

This means your virtual environment is either not active or the install did not complete. Check that (venv) is in your prompt, then re-run pip install -r requirements.txt.

If python opens Python 2 or is not found, try python3 instead.

help me fix ModuleNotFoundError for cryptography or argon2

✔️ Awesome, I've got everything!

Great. Double check you see Ready to break some passwords in your terminal output, and you're all set.

ⓧ I'd like to double check the full code

Your requirements.txt should contain exactly this:

cryptography==49.0.0
argon2-cffi==25.1.0

Your environment is locked and loaded. Next up, you'll build an encrypted password vault that looks perfectly secure from the outside, and then break into it yourself.

Build an Encrypted Password Vault

Before you can understand why encryption is wrong for passwords, you need to see a system that looks perfectly secure from the outside.

In this step, you will build a vault that encrypts every password with a secret key. It looks professional, it works, and from the outside the stored data is unreadable gibberish. In the next step, you will break it yourself.

In this step, get ready to:
  • Create an encrypted password vault that generates and stores a Fernet encryption key.
  • Register users and verify their logins against encrypted stored passwords.
  • Inspect the encrypted database file to confirm the passwords are unreadable.
Create the vault with encryption helpers

Your vault needs three capabilities: managing an encryption key, reading and writing a database file, and encrypting or decrypting passwords. You will build these as small Python functions in a new file.

  • Create a new file called vault_encrypted.py in your password-security-lab folder by pasting in the following code:
import json
import os
from cryptography.fernet import Fernet

# File paths for the encrypted database and encryption key
DB_FILE = "db_encrypted.json"
KEY_FILE = "secret.key"

# Load an existing key from disk, or generate and save a new one
def load_or_create_key():
    if os.path.exists(KEY_FILE):
        with open(KEY_FILE, "rb") as f:
            return f.read()
    key = Fernet.generate_key()
    with open(KEY_FILE, "wb") as f:
        f.write(key)
    return key

# Load the JSON user database (or start with an empty dict)
def load_db():
    if os.path.exists(DB_FILE):
        with open(DB_FILE, "r") as f:
            return json.load(f)
    return {}

# Save the user database back to JSON
def save_db(db):
    with open(DB_FILE, "w") as f:
        json.dump(db, f, indent=2)

What does this code do?

load_or_create_key() checks if a key file already exists on disk. If it does, the function reads and returns it. If not, it generates a fresh Fernet key (a URL-safe base64-encoded 32-byte value) and saves it to secret.key.

load_db() and save_db() handle reading and writing a JSON file that stores usernames mapped to their encrypted passwords.

Now add the two core functions that handle registration and login. These go directly below save_db in the same file.

  • Add the register and login functions to vault_encrypted.py by pasting this code at the bottom of the file:
def register(username, password, fernet):
    db = load_db()
    if username in db:
        print(f"User '{username}' already exists.")
        return
    # Encrypt the password and store the ciphertext
    encrypted = fernet.encrypt(password.encode()).decode()
    db[username] = encrypted
    save_db(db)
    print(f"Registered '{username}' successfully.")

def login(username, password, fernet):
    db = load_db()
    if username not in db:
        print(f"User '{username}' not found.")
        return
    # Decrypt the stored ciphertext and compare to input
    decrypted = fernet.decrypt(db[username].encode()).decode()
    if decrypted == password:
        print(f"Login successful! Welcome, {username}.")
    else:
        print("Incorrect password.")

What does this code do?

register() takes a plaintext password, encrypts it using the Fernet cipher, and stores the resulting ciphertext string in the JSON database. The original password is never saved anywhere.

login() reads the stored ciphertext, decrypts it back to plaintext, and compares it to what the user typed. If they match, login succeeds. This is encryption in action: two-way, reversible transformation.

  • Save vault_encrypted.py.
  • Confirm the module loads without errors by running this command:
python -c "from vault_encrypted import load_or_create_key; print('Module loaded successfully')"

You should see Module loaded successfully printed in your terminal.

Seeing a ModuleNotFoundError?

Make sure your terminal is inside the password-security-lab folder where vault_encrypted.py lives. Also confirm your virtual environment is still activated (you should see (venv) at the start of your terminal prompt).

help me fix a ModuleNotFoundError when importing vault_encrypted

Add a main menu loop

The helper functions are ready, but the vault has no way for a user to interact with it. You need a menu loop that lets someone register and log in from the terminal.

  • Add the main function and entry point to the bottom of vault_encrypted.py by pasting this code:
def main():
    # Load or create the encryption key, then create a Fernet cipher
    key = load_or_create_key()
    fernet = Fernet(key)

    print("=== Encrypted Password Vault ===")
    print("This vault encrypts passwords with Fernet (AES-128-CBC + HMAC-SHA256).\n")

    while True:
        print("\n1. Register")
        print("2. Login")
        print("3. Quit")
        choice = input("\nChoice: ").strip()

        if choice == "1":
            username = input("Username: ").strip()
            password = input("Password: ").strip()
            register(username, password, fernet)
        elif choice == "2":
            username = input("Username: ").strip()
            password = input("Password: ").strip()
            login(username, password, fernet)
        elif choice == "3":
            break
        else:
            print("Invalid choice.")

if __name__ == "__main__":
    main()

What does this code do?

main() first calls load_or_create_key() to get (or generate) the encryption key, then wraps it in a Fernet cipher object. From that point, every registration encrypts with this key and every login decrypts with it.

The while True loop presents a numbered menu. It keeps looping until the user chooses option 3 to quit.

  • Save vault_encrypted.py.

✔️ Awesome, I've got everything!

Great. Confirm you have saved vault_encrypted.py and you are ready to run it.

ⓧ I'd like to double check the full code

Scroll up and confirm your vault_encrypted.py contains all three sections in this order:

  • Imports, constants, and the three helper functions (load_or_create_key, load_db, save_db).
  • The register and login functions.
  • The main() function and the if __name__ == "__main__" entry point at the very bottom.
Run the vault and register users

The vault is complete. Time to use it like a real user would, then peek behind the curtain at how the data is actually stored.

Before you run it, what do you think the stored passwords will look like in the database file? Readable text, or something else?

  • Run the vault by executing this command:
python vault_encrypted.py

You will see a menu with three options: Register, Login, and Quit.

  • Choose option 1 to register.
  • Enter alice as the username.
  • Enter password123 as the password.
  • Register a second user: username bob, password hunter2.
  • Now choose option 2 to log in as alice with password password123.
  • Log in as bob with password hunter2.
  • Choose option 3 to quit.

Both logins should print Login successful! followed by the username.

Login says incorrect password?

Make sure you typed the exact same password during login as you did during registration. Passwords are case-sensitive. If you made a typo during registration, quit the program, delete db_encrypted.json, and re-run to start fresh.

help me debug why my encrypted vault login is failing

Now inspect what the vault actually stored on disk.

  • View the contents of the encrypted database by running:
cat db_encrypted.json

You should see something like this (your values will differ):

{
  "alice": "gAAAAABm...<long base64 string>...",
  "bob": "gAAAAABm...<long base64 string>..."
}

The passwords look like unreadable gibberish. From the outside, this system appears completely secure.

Why does the ciphertext start with gAAAAA?

Fernet tokens are base64-encoded and always begin with a version byte (0x80) followed by a timestamp. When base64-encoded, this produces the recognizable gAAAAA prefix. Every Fernet-encrypted value in your database will start this way.

  • Also confirm the key file was created by running:
ls secret.key

You should see secret.key listed in the output. This single file is the master key to every password in your vault.

What have you proved so far?

You have a working password vault that encrypts user passwords before storing them. The stored data is unreadable without the key. Logins work correctly by decrypting and comparing.

It looks secure. But encryption is reversible by design. Anyone who gets that secret.key file can decrypt every password instantly. You will prove this in the next step.

Your encrypted vault is working perfectly. Next up, you will play the role of an attacker, steal the key file, and read every password in seconds.

Steal the Key and Break the Vault

Your encrypted vault from the previous step looks secure. The passwords stored in db_encrypted.json are unreadable gibberish that means nothing to anyone who opens the file.

But encryption is reversible by design. Anyone who gets the key file can decrypt every single password instantly. A disgruntled employee, a misconfigured backup, a server breach. You are about to prove this yourself by writing an attacker script that steals the key and reads every password in plaintext.

In this step, get ready to:
  • Write an attacker script that reads the encryption key and database.
  • Decrypt and display every stored password in plaintext.
Create the attacker script

This script simulates what happens when an attacker gains access to your server. All they need is two files: the encryption key and the database.

  • Create a new file called break_encryption.py in your password-security-lab folder.
  • Add the imports, constants, and the key-reading logic by pasting this code:
import json
from cryptography.fernet import Fernet

KEY_FILE = "secret.key"
DB_FILE = "db_encrypted.json"


def main():
    print("=== ATTACKER: Breaking the Encrypted Vault ===\n")

    # Step 1: Steal the key (it's just a file on the server)
    with open(KEY_FILE, "rb") as f:
        key = f.read()
    fernet = Fernet(key)
    print(f"[*] Found encryption key: {key.decode()[:20]}...")

    # Step 2: Read the encrypted database
    with open(DB_FILE, "r") as f:
        db = json.load(f)
    print(f"[*] Found {len(db)} users in database.\n")

What does this code do?

  • It opens secret.key and reads the raw key bytes. This is the same key your vault uses to encrypt passwords.
  • It creates a Fernet object from that stolen key, giving the attacker the same decryption power as your vault.
  • It loads the encrypted database with all stored user credentials.
  • Now add the decryption loop below the database-reading code. This is the part that actually cracks every password:
    # Step 3: Decrypt every password
    print("--- DECRYPTED PASSWORDS ---")
    for username, encrypted_password in db.items():
        plaintext = fernet.decrypt(encrypted_password.encode()).decode()
        print(f"  {username}: {plaintext}")

    print("\n[!] All passwords recovered. Encryption is REVERSIBLE.")
    print("[!] Anyone with the key can read every password instantly.")
    print("[!] This is why you NEVER encrypt passwords. You HASH them.")


if __name__ == "__main__":
    main()

What does this code do?

  • The loop iterates over every user in the database and calls fernet.decrypt() to reverse the encryption. Each password is recovered in full plaintext.
  • The print statements at the end highlight the fundamental problem. Encryption is a two-way door. If you can lock it, you can unlock it.
  • Save break_encryption.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running it.

ⓧ I'd like to double check the full code

Scroll up and confirm your file contains both chunks in order:

  • Chunk 1: Imports, constants, and the main() function opening that reads the key file and loads the database.
  • Chunk 2: The decryption loop, the three warning print statements, and the if __name__ == "__main__": block at the bottom.
Run the attack and read every password

Your attacker script is ready. Before you run it, think about what you expect to happen.

The passwords in db_encrypted.json look like unreadable gibberish right now. Do you think this script can actually turn that gibberish back into the original passwords?

  • Run the attacker script by entering this command:
python break_encryption.py

Every username and plaintext password is now printed to your terminal. The "secure" encrypted vault is completely broken.

Why did this work so easily?

Encryption is designed to be reversible. It exists for data you need to read back later, like credit card numbers or medical records.

Passwords are different. Nobody should ever need to read a password back. Not your application, not your database administrator, not you. The only valid operation on a stored password is "does this input match?" That is what hashing provides. A one-way function that can never be reversed.

Seeing a FileNotFoundError?

  • Make sure you ran vault_encrypted.py in the previous step and registered at least one user. That step creates both secret.key and db_encrypted.json.
  • Confirm you are running the script from inside the password-security-lab folder where those files live.

Still stuck? Ask for help.

You just proved that encryption is fundamentally broken for password storage. It does not matter how strong the algorithm is. If the key exists, every password is one line of code away from being exposed. Next up, you will switch to hashing, a one-way function that cannot be reversed. But you will discover it has a different weakness.

Switch to Hashing, Then Crack It

In the previous step, you proved that encryption is fatally flawed for password storage. Anyone who gets the key file can read every password instantly. The fix seems obvious: use a one-way function that cannot be reversed.

That one-way function is called a hash. SHA-256 is the most well-known general-purpose hash. It takes any input and produces a fixed-length string that cannot be reversed back to the original. In this step, you will rebuild your vault with SHA-256, then crack it yourself with a dictionary attack in under a second.

In this step, get ready to:
  • Build a SHA-256 hashed password vault that stores irreversible hashes instead of encrypted ciphertext.
  • Write an attacker script that cracks hashed passwords using a dictionary of common passwords.
  • Prove that SHA-256 is too fast for password storage by cracking your own vault in milliseconds.
Create the SHA-256 hashed vault

SHA-256 is a one-way function. Once you hash a password, there is no key, no secret, and no mathematical operation that can reverse it back to the original. This eliminates the "steal the key" attack entirely.

  • Create a new file called vault_hashed.py in your password-security-lab folder and add the following code:
import hashlib
import json
import os

# File to store hashed passwords
DB_FILE = "db_hashed.json"


def load_db():
    # Load existing user database or create empty one
    if os.path.exists(DB_FILE):
        with open(DB_FILE, "r") as f:
            return json.load(f)
    return {}


def save_db(db):
    with open(DB_FILE, "w") as f:
        json.dump(db, f, indent=2)


def hash_password(password):
    # SHA-256 produces a fixed-length hex string from any input
    return hashlib.sha256(password.encode()).hexdigest()


def register(username, password):
    db = load_db()
    if username in db:
        print(f"User '{username}' already exists.")
        return
    # Store the hash, not the password itself
    db[username] = hash_password(password)
    save_db(db)
    print(f"Registered '{username}' successfully.")

What does this code do?

This first half sets up the vault infrastructure with one critical difference from the encrypted vault.

  • hash_password() uses hashlib.sha256() to turn any password into a 64-character hex string. There is no key involved and no way to reverse it.
  • register() stores the hash output, not the password. Even the system owner cannot read passwords back.
  • Add the login function and main menu below the register function:
def login(username, password):
    db = load_db()
    if username not in db:
        print(f"User '{username}' not found.")
        return
    # Hash the input and compare to stored hash
    if db[username] == hash_password(password):
        print(f"Login successful! Welcome, {username}.")
    else:
        print("Incorrect password.")


def main():
    print("=== SHA-256 Hashed Password Vault ===")
    print("This vault hashes passwords with SHA-256 (one-way, irreversible).\n")

    while True:
        print("\n1. Register")
        print("2. Login")
        print("3. Quit")
        choice = input("\nChoice: ").strip()

        if choice == "1":
            username = input("Username: ").strip()
            password = input("Password: ").strip()
            register(username, password)
        elif choice == "2":
            username = input("Username: ").strip()
            password = input("Password: ").strip()
            login(username, password)
        elif choice == "3":
            break
        else:
            print("Invalid choice.")


if __name__ == "__main__":
    main()

How does login work without decrypting?

The login function hashes whatever the user types and compares it to the stored hash. If both hashes match, the password must be the same. No decryption needed, no key involved.

  • Save vault_hashed.py.
  • Run the vault and register two users by running:
python vault_hashed.py
  • Choose option 1 to register.
  • Enter alice as the username and password123 as the password.
  • Register a second user: bob with password hunter2.
  • Test login with alice and password123 to confirm it works, then choose option 3 to quit.

You should see "Login successful! Welcome, alice." confirming the hash comparison works.

  • Open db_hashed.json in your editor to see what is stored.

You will see long hex strings instead of encrypted blobs. These are SHA-256 hashes. There is no key file this time. No amount of stolen files can reverse these hashes back to passwords.

Why not just use this?

This looks secure. The passwords are irreversible. There is no key to steal. So what is the problem? SHA-256 was designed to be fast. Extremely fast. An attacker does not need to reverse the hash. They just need to try passwords until one matches.

Not seeing the login succeed?

  • Make sure you type the password exactly as you registered it. Hashes are case-sensitive and space-sensitive.
  • Check that db_hashed.json exists in your project folder. If it is missing, the registration did not save.

Help me debug my SHA-256 vault login

✔️ Awesome, I've got everything!

Great. Make sure you have saved vault_hashed.py and that db_hashed.json exists with your two registered users.

ⓧ I'd like to double check the full code

Here is the complete vault_hashed.py for comparison:

import hashlib
import json
import os

DB_FILE = "db_hashed.json"


def load_db():
    if os.path.exists(DB_FILE):
        with open(DB_FILE, "r") as f:
            return json.load(f)
    return {}


def save_db(db):
    with open(DB_FILE, "w") as f:
        json.dump(db, f, indent=2)


def hash_password(password):
    return hashlib.sha256(password.encode()).hexdigest()


def register(username, password):
    db = load_db()
    if username in db:
        print(f"User '{username}' already exists.")
        return
    db[username] = hash_password(password)
    save_db(db)
    print(f"Registered '{username}' successfully.")
def login(username, password):
    db = load_db()
    if username not in db:
        print(f"User '{username}' not found.")
        return
    if db[username] == hash_password(password):
        print(f"Login successful! Welcome, {username}.")
    else:
        print("Incorrect password.")


def main():
    print("=== SHA-256 Hashed Password Vault ===")
    print("This vault hashes passwords with SHA-256 (one-way, irreversible).\n")

    while True:
        print("\n1. Register")
        print("2. Login")
        print("3. Quit")
        choice = input("\nChoice: ").strip()

        if choice == "1":
            username = input("Username: ").strip()
            password = input("Password: ").strip()
            register(username, password)
        elif choice == "2":
            username = input("Username: ").strip()
            password = input("Password: ").strip()
            login(username, password)
        elif choice == "3":
            break
        else:
            print("Invalid choice.")


if __name__ == "__main__":
    main()
Write a dictionary attack script

An attacker who steals your hashed database cannot reverse the hashes. But they do not need to. They can hash every common password and check which hashes match your stored ones. This is called a dictionary attack.

  • Create a new file called crack_hashes.py and add the imports and password dictionary:
import hashlib
import json
import time

DB_FILE = "db_hashed.json"

# Common passwords from well-known breach lists
COMMON_PASSWORDS = [
    "123456", "password", "password123", "12345678", "qwerty",
    "abc123", "hunter2", "letmein", "admin", "welcome",
    "monkey", "dragon", "master", "login", "princess",
    "football", "shadow", "sunshine", "trustno1", "iloveyou",
    "batman", "access", "hello", "charlie", "donald",
]

What is this password list?

COMMON_PASSWORDS is a small sample of passwords that appear in real-world data breaches. In a real attack, lists contain millions of entries. Even this tiny 25-word list is enough to crack most users who pick common passwords.

  • Add the main attack function below the password list:
def main():
    print("=== ATTACKER: Dictionary Attack on SHA-256 Hashes ===\n")

    # Load the stolen hashed database
    with open(DB_FILE, "r") as f:
        db = json.load(f)
    print(f"[*] Loaded {len(db)} hashed passwords from database.")

    # Pre-compute SHA-256 hashes for every common password
    print(f"[*] Building dictionary of {len(COMMON_PASSWORDS)} common passwords...\n")
    rainbow = {}
    for pwd in COMMON_PASSWORDS:
        rainbow[hashlib.sha256(pwd.encode()).hexdigest()] = pwd

    # Compare each stored hash against our dictionary
    start = time.time()
    cracked = 0
    print("--- CRACKED PASSWORDS ---")
    for username, stored_hash in db.items():
        if stored_hash in rainbow:
            print(f"  {username}: {rainbow[stored_hash]}")
            cracked += 1
        else:
            print(f"  {username}: [not in dictionary]")
    elapsed = time.time() - start

    print(f"\n[*] Cracked {cracked}/{len(db)} passwords in {elapsed:.4f} seconds.")
    print(f"[*] SHA-256 is too FAST. An attacker can try billions per second with a GPU.")
    print(f"[!] This is why you need SLOW hashing algorithms designed for passwords.")


if __name__ == "__main__":
    main()

What does this attack do?

  • The rainbow dictionary maps pre-computed SHA-256 hashes back to their plaintext passwords. This is a lookup table.
  • For each stored hash in the database, the script checks if that hash exists in the lookup table. If it does, the password is cracked.
  • time.time() measures how long the attack takes. Spoiler: it will be nearly instant.
  • Save crack_hashes.py.

Seeing a syntax error?

  • Check that the COMMON_PASSWORDS list ends with a closing bracket ] on its own line.
  • Make sure the def main(): line is not indented inside the list.

Help me fix a syntax error in my crack_hashes.py

✔️ Awesome, I've got everything!

Great. Confirm crack_hashes.py is saved and ready to run.

ⓧ I'd like to double check the full code

Here is the complete crack_hashes.py for comparison:

import hashlib
import json
import time

DB_FILE = "db_hashed.json"

COMMON_PASSWORDS = [
    "123456", "password", "password123", "12345678", "qwerty",
    "abc123", "hunter2", "letmein", "admin", "welcome",
    "monkey", "dragon", "master", "login", "princess",
    "football", "shadow", "sunshine", "trustno1", "iloveyou",
    "batman", "access", "hello", "charlie", "donald",
]
def main():
    print("=== ATTACKER: Dictionary Attack on SHA-256 Hashes ===\n")

    with open(DB_FILE, "r") as f:
        db = json.load(f)
    print(f"[*] Loaded {len(db)} hashed passwords from database.")

    print(f"[*] Building dictionary of {len(COMMON_PASSWORDS)} common passwords...\n")
    rainbow = {}
    for pwd in COMMON_PASSWORDS:
        rainbow[hashlib.sha256(pwd.encode()).hexdigest()] = pwd

    start = time.time()
    cracked = 0
    print("--- CRACKED PASSWORDS ---")
    for username, stored_hash in db.items():
        if stored_hash in rainbow:
            print(f"  {username}: {rainbow[stored_hash]}")
            cracked += 1
        else:
            print(f"  {username}: [not in dictionary]")
    elapsed = time.time() - start

    print(f"\n[*] Cracked {cracked}/{len(db)} passwords in {elapsed:.4f} seconds.")
    print(f"[*] SHA-256 is too FAST. An attacker can try billions per second with a GPU.")
    print(f"[!] This is why you need SLOW hashing algorithms designed for passwords.")


if __name__ == "__main__":
    main()
Run the dictionary attack

Your hashed vault has no key to steal. The passwords are stored as irreversible hashes. Before you run the attack, think about what you expect to happen.

Do you think the attack will succeed? The hashes are one-way and cannot be reversed. Take a moment to predict whether your passwords will survive.

  • Run the attacker script:
python crack_hashes.py

Both passwords cracked. In under a second. The script tried just 25 common passwords and found both of yours instantly.

Why did this work?

SHA-256 is a general-purpose hash designed to be as fast as possible. On a single CPU, it can compute tens of millions of hashes per second. A modern GPU pushes that into the billions.

The attacker does not reverse the hash. They simply hash every password in their dictionary and check for matches. When hashing is this fast, trying every word in a dictionary is trivial. SHA-256 was never designed for password storage.

What is a rainbow table?

The rainbow dictionary in your script is a simplified version of a rainbow table. Real rainbow tables contain pre-computed hashes for billions of passwords. An attacker downloads one and cracks hashes by lookup alone, without computing anything at runtime.

You have now broken two systems yourself. Encryption fails because it is reversible. SHA-256 hashing fails because it is too fast. In the next step, you will build the system the right way with Argon2id, a hash that is deliberately slow and memory-hungry, making brute-force attacks economically impossible.

Build the Secure Argon2id Vault and Benchmark It

You have proven that encryption is fundamentally broken for passwords (anyone with the key reads everything) and that SHA-256 is too fast (a dictionary attack cracks common passwords in milliseconds). Both failures share the same root cause: neither algorithm was designed for password storage.

Argon2id solves both problems at once. It is one-way like SHA-256, so there is no key to steal. It is deliberately slow and memory-hungry, so brute-force attacks become economically infeasible. Combined with random per-user salts, it is the OWASP-recommended standard for password storage.

In this step, get ready to:
  • Build a vault that stores passwords with Argon2id and automatic random salts.
  • Prove that random salts make identical passwords produce completely different hashes.
  • Benchmark SHA-256 against Argon2id and see the speed difference that protects your users.
Create the Argon2id vault

This vault follows the same register/login pattern you built twice before. The critical difference is what happens to the password before it gets stored.

  • Create a new file called vault_secure.py in your password-security-lab folder, then paste the following code:
import json
import os
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

DB_FILE = "db_secure.json"

ph = PasswordHasher()

def load_db():
    if os.path.exists(DB_FILE):
        with open(DB_FILE, "r") as f:
            return json.load(f)
    return {}

def save_db(db):
    with open(DB_FILE, "w") as f:
        json.dump(db, f, indent=2)

def register(username, password):
    db = load_db()
    if username in db:
        print(f"User '{username}' already exists.")
        return
    hashed = ph.hash(password)
    db[username] = hashed
    save_db(db)
    print(f"Registered '{username}' successfully.")
    print(f"  Stored hash: {hashed[:60]}...")

What does this code do?

The key differences from your previous vaults:

  • PasswordHasher() creates a hasher configured with Argon2id defaults: time_cost=3, memory_cost=65536 (64 MiB of RAM per hash), and parallelism=4.
  • ph.hash(password) automatically generates a random 16-byte salt, mixes it with the password, and returns a self-describing hash string. No key file to steal.
  • The register function prints the first 60 characters of the stored hash so you can see the Argon2id format.
  • Verify your imports work by running this quick test:
python -c "from vault_secure import register; register('alice', 'password123')"

You should see a line like Registered 'alice' successfully. followed by a stored hash starting with $argon2id$v=19$m=65536,t=3,p=4$.

Seeing an ImportError?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • Check that argon2-cffi is installed by running pip list | grep argon2.

Help me debug this Argon2 import error.

  • Now add the login, salt demo, and main menu below your existing code in vault_secure.py:
def login(username, password):
    db = load_db()
    if username not in db:
        print(f"User '{username}' not found.")
        return
    try:
        ph.verify(db[username], password)
        print(f"Login successful! Welcome, {username}.")
    except VerifyMismatchError:
        print("Incorrect password.")

def show_salt_demo():
    print("\n--- Salt Demonstration ---")
    password = "password123"
    hash1 = ph.hash(password)
    hash2 = ph.hash(password)
    print(f"Same password hashed twice:")
    print(f"  Hash 1: {hash1}")
    print(f"  Hash 2: {hash2}")
    print(f"  Are they the same? {hash1 == hash2}")
    print(f"  (Random salts make every hash unique!)\n")

What is different about login here?

Instead of decrypting or comparing hex digests, ph.verify(stored_hash, password) re-derives the hash from the input password using the salt embedded in the stored string. If it matches, it returns True. If not, it raises VerifyMismatchError.

The show_salt_demo function hashes the same password twice and shows that the results are completely different strings. Random salts mean precomputed lookup tables are useless.

  • Finally, add the main menu loop at the bottom of vault_secure.py:
def main():
    print("=== Argon2id Secure Password Vault ===")
    print("This vault uses Argon2id (OWASP 2026 recommended).")
    print("Parameters: time_cost=3, memory_cost=64 MiB, parallelism=4\n")
    while True:
        print("\n1. Register")
        print("2. Login")
        print("3. Salt Demo")
        print("4. Quit")
        choice = input("\nChoice: ").strip()
        if choice == "1":
            username = input("Username: ").strip()
            password = input("Password: ").strip()
            register(username, password)
        elif choice == "2":
            username = input("Username: ").strip()
            password = input("Password: ").strip()
            login(username, password)
        elif choice == "3":
            show_salt_demo()
        elif choice == "4":
            break
        else:
            print("Invalid choice.")

if __name__ == "__main__":
    main()
  • Save vault_secure.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before running it.

ⓧ I'd like to double check the full code

Scroll up and confirm your vault_secure.py contains all three chunks in order:

  • Chunk 1: Imports, DB_FILE, ph = PasswordHasher(), load_db, save_db, and register.
  • Chunk 2: login and show_salt_demo.
  • Chunk 3: main() with the menu loop and if __name__ == "__main__" at the bottom.
Register users and run the salt demo

Now run the vault and register users with the same passwords you used earlier, so you can compare how differently Argon2id stores them.

  • Run the secure vault:
python vault_secure.py
  • Choose option 1 to register.
  • Enter bob as the username and hunter2 as the password.
  • Register another user if you like, then choose option 2 to log in with bob and hunter2.

You should see a successful login message. Notice the stored hash printed during registration starts with $argon2id$v=19$m=65536,t=3,p=4$. This string embeds the algorithm, version, and all parameters. It is self-describing and upgradeable.

  • Now choose option 3 (Salt Demo).

The demo hashes the password "password123" twice and prints both hashes. They are completely different strings, even though the input is identical. The answer to "Are they the same?" is False.

Why do random salts matter?

In your SHA-256 vault, the same password always produced the same hash. An attacker could precompute a table of common password hashes (a rainbow table) and look up matches instantly.

Random salts destroy this shortcut. Every user gets a unique salt mixed into their hash, so the attacker must brute-force each account individually. Argon2id generates a random 16-byte salt automatically on every call to ph.hash().

  • Choose option 4 to quit the program.
  • Open db_secure.json in your editor and examine the stored hashes.

Each hash is a long string starting with $argon2id$. Compare this to db_encrypted.json (where the key unlocks everything) and db_hashed.json (where a dictionary attack cracked it in milliseconds). The Argon2id hashes cannot be reversed, and they cannot be looked up in a precomputed table.

Login failing with the correct password?

  • Make sure you are running vault_secure.py (not vault_hashed.py). The databases are separate files.
  • If you registered alice via the import test earlier, she only exists in db_secure.json from that test run. Try logging in as alice with password123.

Help me debug why my Argon2id vault login is failing.

Create and run the benchmark

You know Argon2id is "slow on purpose" but how slow is it really? A benchmark makes the difference concrete and measurable.

  • Create a new file called benchmark.py in your password-security-lab folder, then paste the following code:
import hashlib
import time
from argon2 import PasswordHasher

ph = PasswordHasher()

def benchmark_sha256(iterations):
    start = time.time()
    for i in range(iterations):
        hashlib.sha256(f"password{i}".encode()).hexdigest()
    elapsed = time.time() - start
    return elapsed

def benchmark_argon2(iterations):
    start = time.time()
    for i in range(iterations):
        ph.hash(f"password{i}")
    elapsed = time.time() - start
    return elapsed

What does this code do?

  • benchmark_sha256 hashes 100,000 passwords with SHA-256 and measures the total time.
  • benchmark_argon2 hashes only 5 passwords with Argon2id. Even 5 hashes takes noticeably long because each one allocates 64 MiB of RAM and runs multiple passes.
  • Add the main function and entry point below your benchmark functions in benchmark.py:
def main():
    print("=== Password Hashing Speed Benchmark ===\n")

    sha_count = 100_000
    print(f"SHA-256: Hashing {sha_count:,} passwords...")
    sha_time = benchmark_sha256(sha_count)
    sha_per_sec = sha_count / sha_time
    print(f"  Time: {sha_time:.3f} seconds")
    print(f"  Speed: {sha_per_sec:,.0f} hashes/second\n")

    argon_count = 5
    print(f"Argon2id: Hashing {argon_count} passwords...")
    argon_time = benchmark_argon2(argon_count)
    argon_per_sec = argon_count / argon_time
    print(f"  Time: {argon_time:.3f} seconds")
    print(f"  Speed: {argon_per_sec:.1f} hashes/second\n")

    print("--- What This Means for an Attacker ---")
    print(f"  SHA-256:  {sha_per_sec:,.0f} password guesses/second (on YOUR machine)")
    print(f"  Argon2id: {argon_per_sec:.1f} password guesses/second (on YOUR machine)")
    print(f"\n  Slowdown factor: {sha_per_sec / argon_per_sec:,.0f}x")
    print(f"\n  With SHA-256, cracking a 6-char password: seconds")
    print(f"  With Argon2id, cracking a 6-char password: years")
    print(f"\n  This is why Argon2id exists. Slowness IS the feature.")

if __name__ == "__main__":
    main()

What will the output show?

The main function times both algorithms, calculates hashes-per-second for each, then prints how many password guesses an attacker could make per second. The slowdown factor shows how many times harder Argon2id makes brute-force attacks compared to SHA-256.

  • Save benchmark.py.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before running the benchmark.

ⓧ I'd like to double check the full code

Scroll up and confirm your benchmark.py contains both chunks in order:

  • Chunk 1: Imports, ph = PasswordHasher(), benchmark_sha256, and benchmark_argon2.
  • Chunk 2: main() with the timing logic, comparison printout, and if __name__ == "__main__" at the bottom.

Before you run this, predict: how many times faster do you think SHA-256 will be compared to Argon2id? 10x? 100x? 1,000x?

  • Run the benchmark:
python benchmark.py

You will see SHA-256 process hundreds of thousands (or millions) of hashes per second. Argon2id processes only a handful. The slowdown factor is typically tens of thousands to hundreds of thousands.

Why is slowness the point?

Every hash SHA-256 can compute per second is a password guess an attacker gets for free. When the algorithm processes millions per second, an attacker's GPU cluster can try billions.

Argon2id flips this equation. Each guess costs 64 MiB of RAM and hundreds of milliseconds of CPU time. A six-character password that falls to SHA-256 in seconds would take years to crack with Argon2id. The deliberate slowness is the security feature.

Benchmark taking longer than expected?

  • The Argon2id portion (5 hashes) typically takes 1-3 seconds. This is normal and intentional.
  • If SHA-256 is very slow (more than 5 seconds for 100,000 hashes), your machine may be under heavy load. Close other applications and try again.

Help me understand my benchmark results.

You now have concrete proof that Argon2id is the right tool for password storage. It cannot be reversed like encryption. It cannot be brute-forced like SHA-256. Next up, you will push this project to GitHub and tackle the Secret Mission.

Secret mission

Breach Detection and Automatic Rehashing

Your secure vault stores passwords correctly, but production systems need more. What happens when a user picks a password that has already been leaked in a data breach? What happens when you upgrade your Argon2id parameters for stronger security, but existing hashes use the old settings? Time to solve both problems.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The password-security-lab project folder on your Desktop (contains all Python scripts, virtual environment, database files, and key files).
  • GitHub repository with your pushed project code.

Keep everything running

No action needed. Choose this if you want to keep experimenting with the password vaults or extend the project further.

  • Your password-security-lab folder stays on your Desktop with all scripts and data files intact.
  • Your GitHub repository remains public (or private, depending on how you created it).
  • The virtual environment stays ready. Reactivate it any time with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).

Pause - I'll come back to this later

Deactivate your virtual environment to free up terminal state. Your files stay on disk for when you return.

  • If your virtual environment is still active, deactivate it by running:
deactivate
  • Your project folder and GitHub repository remain untouched.
  • When you come back, navigate to password-security-lab and reactivate the virtual environment.

Delete - I don't want to use this again

Remove all project files from your machine and delete the GitHub repository.

  • If your virtual environment is still active, deactivate it first by running:
deactivate
  • Delete the entire project folder by running:

macOS

cd ~/Desktop
rm -rf password-security-lab

Windows

cd ~\Desktop
rmdir /s /q password-security-lab
  • To delete your GitHub repository, go to your repository page on GitHub.
  • Click Settings.
  • Scroll to the bottom to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Nice Work!

Nice Work!

You just built a complete password security system from scratch, broke it yourself twice, and rebuilt it the right way. You now understand not just how to hash passwords, but why every alternative fails.

You've learned how to:

  • Build and break an encrypted password vault, proving that encryption is reversible and fundamentally wrong for storing passwords.
  • Crack SHA-256 hashed passwords with a dictionary attack in milliseconds, proving that fast hashing algorithms are dangerously weak for password storage.
  • Build a production-ready Argon2id password system with automatic salting and self-describing hash strings, and prove with a benchmark that it reduces attacker throughput from millions of guesses per second to single digits.
  • (Secret Mission) Add breach detection and automatic rehashing, the same patterns used by production authentication systems at companies like GitHub and Stripe.

Ready to quiz yourself?