Skip to main content
Educational Purpose Only - Plaintext password storage is a critical security flaw that puts all user accounts at risk. This demo shows why proper password hashing is essential.

Overview

Insecure password storage occurs when applications store passwords in plaintext or use weak hashing algorithms without proper salting. In this demo, the vulnerable version stores passwords directly in the database without any hashing, making all user credentials immediately accessible if the database is compromised.

Vulnerable Code

The vulnerable implementation stores passwords in plaintext:
# VULNERABLE: Plaintext password storage
try:
    cursor.execute("""
        INSERT INTO users (username, password, email, role) 
        VALUES (?, ?, ?, ?)
    """, ('admin', 'admin123', '[email protected]', 'admin'))  # Plaintext!
    
    cursor.execute("""
        INSERT INTO users (username, password, email) 
        VALUES (?, ?, ?)
    """, ('usuario', 'password123', '[email protected]'))  # Plaintext!
except sqlite3.IntegrityError:
    pass

Why This Is Dangerous

  1. No Hashing: Passwords stored as-is in the database
  2. Database Compromise = Complete Breach: Anyone with database access sees all passwords immediately
  3. SQL Injection Exposure: Password comparison in SQL query (see vulnerable/app.py:26)
  4. Password Reuse Risk: Users often reuse passwords across sites
  5. No Salt: Even if basic hashing was added, without salt it’s vulnerable to rainbow table attacks
  6. Compliance Violations: Violates GDPR, PCI-DSS, HIPAA, and other regulations

Attack Scenarios

Attack Vector: Attacker obtains database backup through:
  • Misconfigured cloud storage
  • Compromised backup server
  • Insider threat
  • SQL injection (data exfiltration)
Impact: Immediate access to all user passwords
-- Attacker's query on stolen database
SELECT username, password, email FROM users;

-- Results:
-- admin     | admin123      | [email protected]
-- usuario   | password123   | [email protected]
-- john      | MyP@ssw0rd!   | [email protected]
No cracking needed - passwords are plaintext!
Attack: Combine with SQL injection vulnerability
# Exploit the SQLi vulnerability to extract passwords
payload = "' UNION SELECT id, username, password, email, role, created_at FROM users --"

# Sends query:
# SELECT * FROM users WHERE username = '' UNION SELECT id, username, password, email, role, created_at FROM users --'
Result: Complete user table with plaintext passwords dumped to screen
Attack Flow:
  1. Attacker obtains plaintext passwords from this site
  2. Tests same username/password combinations on:
    • Gmail
    • Banking sites
    • Social media
    • Corporate email
  3. Many users reuse passwords - accounts compromised
Statistics: ~65% of users reuse passwords across sites
Risk: Any employee with database access can:
  • View all user passwords
  • Log into any account
  • Steal credentials for external use
  • No audit trail of password access
Real Example: Uber engineer accessed rider data in 2016

Database Evidence

Let’s see what the vulnerable database actually contains:
-- Vulnerable database structure
CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    password TEXT NOT NULL,  -- Plaintext!
    email TEXT,
    role TEXT DEFAULT 'user',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Sample vulnerable data
SELECT * FROM users;
idusernamepasswordemailrolecreated_at
1adminadmin123[email protected]admin2024-03-10
2usuariopassword123[email protected]user2024-03-10
Anyone with read access to this database sees passwords immediately. No cracking tools needed.

Secure Implementation

The secure version uses bcrypt password hashing (secure/database.py and secure/app.py):
from werkzeug.security import generate_password_hash

def setup_database():
    conn = sqlite3.connect(get_db_path())
    cursor = conn.cursor()
    
    # Create index for performance
    cursor.execute("CREATE INDEX IF NOT EXISTS idx_username ON users(username)")
    
    # SECURE: Hash passwords before storage
    admin_password = generate_password_hash('admin123')
    user_password = generate_password_hash('password123')
    
    try:
        cursor.execute("""
            INSERT INTO users (username, password, email, role) 
            VALUES (?, ?, ?, ?)
        """, ('admin', admin_password, '[email protected]', 'admin'))
        
        cursor.execute("""
            INSERT INTO users (username, password, email) 
            VALUES (?, ?, ?)
        """, ('usuario', user_password, '[email protected]'))
    except sqlite3.IntegrityError:
        pass

How Bcrypt Works

1

Password Input

User enters password: MySecretPassword123
2

Salt Generation

Bcrypt generates a random salt (unique per password):
Salt: $2b$12$KIXn9BZmN8LFj5r7L8QZ3e
The salt ensures identical passwords produce different hashes.
3

Hashing with Work Factor

Bcrypt applies the hashing algorithm multiple times (2^12 = 4096 rounds by default):
# Work factor = 12 (configurable)
hash = bcrypt(password, salt, work_factor=12)
This makes brute-force attacks computationally expensive.
4

Stored Hash

Final hash stored in database:
$2b$12$KIXn9BZmN8LFj5r7L8QZ3eMjH9m8sXqFj5r7L8QZ3eMjH9m8sXqFje
Format: $algorithm$cost$salt$hash
  • $2b$: Bcrypt algorithm identifier
  • $12$: Work factor (2^12 iterations)
  • Next 22 chars: Salt
  • Remaining: Actual password hash
5

Verification

During login:
# Extract salt from stored hash
# Hash provided password with same salt
# Compare result with stored hash
if check_password_hash(stored_hash, provided_password):
    # Login success

Secure Database Evidence

Here’s what the secure database contains:
SELECT * FROM users;
idusernamepasswordemailrole
1admin2b2b12$kR7L8…[email protected]admin
2usuario2b2b12$9mF3X…[email protected]user
Even if the database is compromised, passwords are protected by bcrypt hashing. Cracking would require billions of attempts per password.

Password Hashing Best Practices

Recommended:
  • Bcrypt (used in this demo) - Work factor adjustable
  • Argon2 - Winner of Password Hashing Competition 2015
  • scrypt - Memory-hard function
  • PBKDF2 - NIST approved, but slower per iteration
Never Use:
  • MD5 - Broken, too fast
  • SHA1 - Deprecated
  • SHA256/512 without many iterations - Too fast for passwords
  • Custom algorithms - Likely to be flawed
# Good: Bcrypt
from werkzeug.security import generate_password_hash, check_password_hash
hash = generate_password_hash(password, method='pbkdf2:sha256')

# Better: Argon2 (requires argon2-cffi)
from argon2 import PasswordHasher
ph = PasswordHasher()
hash = ph.hash(password)

# Bad: Plain SHA256
import hashlib
hash = hashlib.sha256(password.encode()).hexdigest()  # DON'T DO THIS!
Configure algorithm difficulty to resist brute-force:
# Bcrypt work factor (rounds = 2^work_factor)
from passlib.hash import bcrypt

# Too weak: work_factor=4 (16 rounds) - 0.001 seconds
hash = bcrypt.using(rounds=4).hash(password)

# Good: work_factor=12 (4096 rounds) - 0.3 seconds
hash = bcrypt.using(rounds=12).hash(password)  # DEFAULT

# Better: work_factor=14 (16384 rounds) - 1.2 seconds
hash = bcrypt.using(rounds=14).hash(password)
Rule of Thumb: Hashing should take 250ms-500ms on your server
Salts prevent rainbow table attacks:
# Good: Automatic salting (bcrypt, werkzeug)
hash = generate_password_hash(password)  # Salt included

# Bad: No salt
hash = hashlib.sha256(password.encode()).hexdigest()

# Bad: Static salt (same for all passwords)
STATIC_SALT = "myappsalt"
hash = hashlib.sha256((password + STATIC_SALT).encode()).hexdigest()
Modern algorithms like bcrypt include random salt automatically.
Enforce strong passwords:
import re

def validate_password(password):
    if len(password) < 12:
        return "Password must be at least 12 characters"
    
    if not re.search(r'[A-Z]', password):
        return "Password must contain uppercase letter"
    
    if not re.search(r'[a-z]', password):
        return "Password must contain lowercase letter"
    
    if not re.search(r'[0-9]', password):
        return "Password must contain number"
    
    if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
        return "Password must contain special character"
    
    # Check against common passwords
    with open('common_passwords.txt') as f:
        if password in f.read().splitlines():
            return "Password is too common"
    
    return None  # Valid
# Bad: Logging passwords
logger.info(f"User {username} logged in with password {password}")

# Good: Never log passwords
logger.info(f"User {username} logged in")

# Bad: Sending password in URL
return redirect(f"/verify?password={password}")

# Good: Use POST and HTTPS
# Always transmit passwords over HTTPS only
# Use POST requests, never GET
Force periodic password changes:
# Database schema
CREATE TABLE users (
    ...
    password_changed_at TIMESTAMP,
    password_history TEXT  -- Store hash of last 5 passwords
);

# Check if password is expired
def is_password_expired(user):
    ninety_days_ago = datetime.now() - timedelta(days=90)
    return user.password_changed_at < ninety_days_ago

# Prevent password reuse
def check_password_history(user, new_password):
    history = json.loads(user.password_history or '[]')
    for old_hash in history:
        if check_password_hash(old_hash, new_password):
            return "Cannot reuse recent passwords"
    return None

Cracking Comparison

Time to crack an 8-character password with different storage methods:
Storage MethodTime to CrackSecurity Level
PlaintextInstantNone
MD51 minuteVery Weak
SHA256 (no salt)5 minutesWeak
SHA256 + salt30 minutesWeak
PBKDF2 (10k iterations)3 daysModerate
Bcrypt (work=12)2 yearsStrong
Bcrypt (work=14)8 yearsVery Strong
Argon210+ yearsExcellent
Times based on modern GPU (RTX 4090). Actual times vary by password complexity.

Migration Strategy

If you have existing plaintext passwords:
1

Add Hash Column

ALTER TABLE users ADD COLUMN password_hash VARCHAR(255);
2

Hybrid Authentication

def login(username, password):
    user = get_user(username)
    
    # Check if already migrated
    if user.password_hash:
        # New method: check hash
        if check_password_hash(user.password_hash, password):
            return True
    
    # Old method: plaintext comparison
    elif user.password == password:
        # UPGRADE: Hash password now
        user.password_hash = generate_password_hash(password)
        user.password = None  # Clear plaintext
        user.save()
        return True
    
    return False
3

Force Password Reset (Recommended)

# Safer approach: Invalidate all passwords
cursor.execute("UPDATE users SET password = NULL, requires_reset = 1")

# Send password reset emails to all users
for user in get_all_users():
    send_password_reset_email(user.email)

Compliance Requirements

GDPR

Article 32: Appropriate technical measuresRequirement: Pseudonymization and encryption of personal dataPenalty: Up to €20 million or 4% of global revenue

PCI-DSS

Requirement 8.2.1: Render passwords unreadable using strong cryptographyStandard: Minimum one-way hash with saltPenalty: Fines up to $500,000 per incident

HIPAA

164.312(a)(2)(iv): Encryption and decryptionRequirement: Strong password protection for PHI accessPenalty: Up to $1.5 million per violation category per year

SOC 2

CC6.1: Logical access controlsRequirement: Strong password hashing required for complianceImpact: Failed audits, lost business

References

Next Steps

Related security topics:

Build docs developers (and LLMs) love