Skip to main content
The Normo Unsecure PWA stores passwords in plaintext - one of the most critical security vulnerabilities possible. This demonstrates what to never do in production.

Overview

Passwords should never be stored in plaintext. When a database is compromised, attackers gain immediate access to all user credentials, which can then be used to compromise other accounts (since users often reuse passwords). This page covers the critical password storage vulnerability in the application and how to implement secure password hashing using bcrypt.

The Plaintext Password Problem

Vulnerable User Registration

The insertUser() function in user_management.py:6-14 stores passwords directly without any encryption:
user_management.py
def insertUser(username, password, DoB):
    con = sql.connect("database_files/database.db")
    cur = con.cursor()
    cur.execute(
        "INSERT INTO users (username,password,dateOfBirth) VALUES (?,?,?)",
        (username, password, DoB),  # PASSWORD STORED IN PLAINTEXT!
    )
    con.commit()
    con.close()
This function takes the password directly from the signup form and stores it in the database without any hashing, encryption, or protection.

Vulnerable Authentication

The retrieveUsers() function in user_management.py:17-39 compares passwords in plaintext:
user_management.py
def retrieveUsers(username, password):
    con = sql.connect("database_files/database.db")
    cur = con.cursor()
    cur.execute(f"SELECT * FROM users WHERE username = '{username}'")
    if cur.fetchone() == None:
        con.close()
        return False
    else:
        # Comparing plaintext passwords!
        cur.execute(f"SELECT * FROM users WHERE password = '{password}'")
        if cur.fetchone() == None:
            con.close()
            return False
        else:
            con.close()
            return True

Impact of Plaintext Storage

If the database is compromised, attackers get:
  • All usernames in plaintext
  • All passwords in plaintext
  • Complete account access
  • Ability to use credentials on other sites
Example database contents:
SELECT * FROM users;

| id | username | password      | dateOfBirth |
|----|----------|---------------|-------------|
| 1  | admin    | Admin123!     | 1990-01-01  |
| 2  | john     | MyPassword1   | 1985-05-15  |
| 3  | jane     | SuperSecret99 | 1992-08-22  |

Understanding Password Security Concepts

Encryption vs Hashing vs Salting

# Encryption can be DECRYPTED back to original
from cryptography.fernet import Fernet

key = Fernet.generate_key()
cipher = Fernet(key)

# Encrypt
encrypted = cipher.encrypt(b"MyPassword123")
print(f"Encrypted: {encrypted}")

# Decrypt (THIS IS THE PROBLEM!)
decrypted = cipher.decrypt(encrypted)
print(f"Decrypted: {decrypted}")  # Gets original password back

# ❌ Don't use encryption for passwords!
# If the key is compromised, all passwords can be decrypted

Why BCrypt?

Encryption: Converts data that can be decrypted back to original with a key
  • ❌ Not suitable for passwords (if key is stolen, all passwords compromised)
Hashing: One-way function that cannot be reversed
  • ✅ Good foundation for password storage
  • ❌ Vulnerable to rainbow tables without salt
Salting: Random data added before hashing
  • ✅ Makes rainbow tables ineffective
  • ✅ Same password produces different hashes
BCrypt: Salted password hashing algorithm
  • ✅ Automatically handles salting
  • ✅ Configurable computational cost
  • ✅ Resistant to brute force attacks
  • ✅ Industry standard for password storage

Byte Strings Explained

To store anything in a computer, you must first encode it (convert it to bytes):
# Character string (human-readable)
my_string = "This is a string"
print(type(my_string))  # <class 'str'>

# Byte string (computer-readable)
my_byte_string = b"This is a byte string"
print(type(my_byte_string))  # <class 'bytes'>

# Encoding: string -> bytes
encoded = my_string.encode('utf-8')
print(encoded)  # b'This is a string'

# Decoding: bytes -> string
decoded = encoded.decode('utf-8')
print(decoded)  # This is a string
BCrypt works with byte strings, so passwords must be encoded before hashing and decoded for display.

Implementing Secure Password Storage with BCrypt

Installation

pip install bcrypt

Basic BCrypt Usage

Here’s the complete example from .student_resources/encrypting_passwords/example.py:
example.py
import bcrypt

# Plain text password
my_password = "I Am All The Jedi"

# UTF-8 is the default Python character set
my_encoded_password = my_password.encode()

# Salt to add to password before Hashing
salt = b"$2b$12$ieYNkQp8QumgedUo30nuPO"

# Hashed Password
hashed_password = bcrypt.hashpw(password=my_encoded_password, salt=salt)

print(f"How actual password will appear in logs etc: {my_encoded_password.hex()}")

# Python print statement will decode it but if the variable is logged, 
# it will be logged as a string of bytes
print(f"Actual Password: {my_encoded_password.decode()}")

# Print Hashed Password
print(f"Hashed Password: {hashed_password.decode()}")

# Check if a plain text password matches a hashed password. 
# It returns a Boolean value.
print(f"Are they the same password: {bcrypt.checkpw(my_encoded_password, hashed_password)}")
Output:
How actual password will appear in logs etc: 492041 6d20416c6c20546865204a656469
Actual Password: I Am All The Jedi
Hashed Password: $2b$12$ieYNkQp8QumgedUo30nuPOKjQvBXr7y3YGkbTzHwXwH.hVKMz4Coe
Are they the same password: True

Secure User Registration

Update user_management.py to hash passwords during registration:
import sqlite3 as sql
import bcrypt

def insertUser(username, password, DoB):
    """
    Secure user registration with password hashing
    """
    con = sql.connect("database_files/database.db")
    cur = con.cursor()
    
    # Generate a salt and hash the password
    salt = bcrypt.gensalt(rounds=12)  # 12 rounds is a good balance
    hashed_password = bcrypt.hashpw(password.encode('utf-8'), salt)
    
    # Store the hashed password, not plaintext!
    cur.execute(
        "INSERT INTO users (username, password, dateOfBirth) VALUES (?, ?, ?)",
        (username, hashed_password, DoB)
    )
    
    con.commit()
    con.close()
    
    print(f"User {username} created with hashed password")

Secure Authentication

Update retrieveUsers() to verify hashed passwords:
import sqlite3 as sql
import bcrypt

def retrieveUsers(username, password):
    """
    Secure authentication with password hashing verification
    """
    con = sql.connect("database_files/database.db")
    cur = con.cursor()
    
    # Use parameterized query to prevent SQL injection
    cur.execute("SELECT * FROM users WHERE username = ?", (username,))
    user = cur.fetchone()
    
    if user is None:
        con.close()
        return False
    
    # Get the stored hashed password
    stored_hash = user[1]  # Assuming password is in column index 1
    
    # Verify the password using bcrypt
    try:
        # bcrypt.checkpw handles the comparison securely
        if bcrypt.checkpw(password.encode('utf-8'), stored_hash):
            con.close()
            return True
        else:
            con.close()
            return False
    except Exception as e:
        print(f"Password verification error: {e}")
        con.close()
        return False

Database Schema Update

Update your database schema to store binary hashed passwords:
CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT UNIQUE NOT NULL,
    password BLOB NOT NULL,  -- Changed from TEXT to BLOB for binary hash
    dateOfBirth TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Complete Secure Implementation

Here’s a complete, production-ready implementation:
import sqlite3 as sql
import bcrypt
import logging
from datetime import datetime

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def insertUser(username, password, DoB):
    """
    Securely create a new user with hashed password
    
    Args:
        username: Unique username
        password: Plaintext password (will be hashed)
        DoB: Date of birth
        
    Returns:
        bool: True if successful, False otherwise
    """
    try:
        con = sql.connect("database_files/database.db")
        cur = con.cursor()
        
        # Check if username already exists
        cur.execute("SELECT username FROM users WHERE username = ?", (username,))
        if cur.fetchone():
            logger.warning(f"Username {username} already exists")
            con.close()
            return False
        
        # Generate salt and hash password
        salt = bcrypt.gensalt(rounds=12)
        hashed_password = bcrypt.hashpw(password.encode('utf-8'), salt)
        
        # Insert user with hashed password
        cur.execute(
            "INSERT INTO users (username, password, dateOfBirth, created_at) VALUES (?, ?, ?, ?)",
            (username, hashed_password, DoB, datetime.now())
        )
        
        con.commit()
        con.close()
        
        logger.info(f"User {username} created successfully")
        return True
        
    except sql.Error as e:
        logger.error(f"Database error during user creation: {e}")
        return False
    except Exception as e:
        logger.error(f"Unexpected error during user creation: {e}")
        return False

def retrieveUsers(username, password):
    """
    Securely authenticate a user
    
    Args:
        username: Username to authenticate
        password: Plaintext password to verify
        
    Returns:
        bool: True if authenticated, False otherwise
    """
    try:
        con = sql.connect("database_files/database.db")
        cur = con.cursor()
        
        # Retrieve user with parameterized query
        cur.execute("SELECT password FROM users WHERE username = ?", (username,))
        user = cur.fetchone()
        
        if user is None:
            logger.warning(f"Login attempt for non-existent user: {username}")
            con.close()
            # Use same timing as password check to prevent username enumeration
            bcrypt.checkpw(b"dummy", bcrypt.hashpw(b"dummy", bcrypt.gensalt()))
            return False
        
        stored_hash = user[0]
        
        # Verify password
        if bcrypt.checkpw(password.encode('utf-8'), stored_hash):
            logger.info(f"Successful login for user: {username}")
            con.close()
            return True
        else:
            logger.warning(f"Failed login attempt for user: {username}")
            con.close()
            return False
            
    except sql.Error as e:
        logger.error(f"Database error during authentication: {e}")
        return False
    except Exception as e:
        logger.error(f"Unexpected error during authentication: {e}")
        return False

def updatePassword(username, old_password, new_password):
    """
    Securely update a user's password
    
    Args:
        username: Username
        old_password: Current password (for verification)
        new_password: New password to set
        
    Returns:
        bool: True if successful, False otherwise
    """
    # First verify old password
    if not retrieveUsers(username, old_password):
        return False
    
    try:
        con = sql.connect("database_files/database.db")
        cur = con.cursor()
        
        # Hash new password
        salt = bcrypt.gensalt(rounds=12)
        hashed_password = bcrypt.hashpw(new_password.encode('utf-8'), salt)
        
        # Update password
        cur.execute(
            "UPDATE users SET password = ? WHERE username = ?",
            (hashed_password, username)
        )
        
        con.commit()
        con.close()
        
        logger.info(f"Password updated for user: {username}")
        return True
        
    except sql.Error as e:
        logger.error(f"Database error during password update: {e}")
        return False

Password Hashing Best Practices

1

Use BCrypt or Argon2

Use industry-standard password hashing algorithms:
  • BCrypt - Battle-tested, configurable cost factor
  • Argon2 - Winner of Password Hashing Competition
  • MD5 - Cryptographically broken
  • SHA-1 - Deprecated for security
  • SHA-256 - Too fast, vulnerable to brute force
2

Configure Appropriate Cost Factor

Balance security and performance:
# BCrypt rounds (cost factor)
salt = bcrypt.gensalt(rounds=12)  # Default: good balance
# rounds=10: ~100ms
# rounds=12: ~250ms (recommended)
# rounds=14: ~1000ms (high security)
3

Never Store Plaintext Passwords

Never, ever store passwords in plaintext:
  • Not in databases
  • Not in log files
  • Not in configuration files
  • Not in error messages
  • Not in backup files
4

Handle Encoding Properly

BCrypt requires byte strings:
# Encode before hashing
password_bytes = password.encode('utf-8')
hashed = bcrypt.hashpw(password_bytes, salt)

# Decode for display (if needed)
hashed_str = hashed.decode('utf-8')
5

Prevent Timing Attacks

Use constant-time comparison:
# BCrypt's checkpw already uses constant-time comparison
is_valid = bcrypt.checkpw(password.encode(), stored_hash)

# For username enumeration, add dummy check
if user is None:
    bcrypt.checkpw(b"dummy", bcrypt.hashpw(b"dummy", bcrypt.gensalt()))
    return False

Testing Password Security

test_passwords.py
import bcrypt
import user_management as dbHandler

def test_password_hashing():
    """Test that passwords are hashed properly"""
    password = "TestPassword123!"
    
    # Create user
    dbHandler.insertUser("testuser", password, "2000-01-01")
    
    # Retrieve from database
    con = sql.connect("database_files/database.db")
    cur = con.cursor()
    cur.execute("SELECT password FROM users WHERE username = ?", ("testuser",))
    stored_password = cur.fetchone()[0]
    con.close()
    
    # Verify password is hashed
    assert stored_password != password, "Password is stored in plaintext!"
    assert stored_password.startswith(b"$2b$"), "Password is not bcrypt hashed!"
    
    print("✅ Password hashing test passed")

def test_password_verification():
    """Test that password verification works"""
    username = "testuser2"
    password = "AnotherPassword456!"
    
    # Create user
    dbHandler.insertUser(username, password, "1995-05-15")
    
    # Test correct password
    assert dbHandler.retrieveUsers(username, password) == True
    
    # Test incorrect password
    assert dbHandler.retrieveUsers(username, "WrongPassword") == False
    
    print("✅ Password verification test passed")

def test_unique_salts():
    """Test that same password produces different hashes"""
    password = "SamePassword789!"
    
    # Create two users with same password
    dbHandler.insertUser("user1", password, "1990-01-01")
    dbHandler.insertUser("user2", password, "1990-01-01")
    
    # Get hashes from database
    con = sql.connect("database_files/database.db")
    cur = con.cursor()
    
    cur.execute("SELECT password FROM users WHERE username = ?", ("user1",))
    hash1 = cur.fetchone()[0]
    
    cur.execute("SELECT password FROM users WHERE username = ?", ("user2",))
    hash2 = cur.fetchone()[0]
    
    con.close()
    
    # Verify hashes are different (unique salts)
    assert hash1 != hash2, "Same password produced same hash - salt not working!"
    
    print("✅ Unique salt test passed")

if __name__ == "__main__":
    test_password_hashing()
    test_password_verification()
    test_unique_salts()
    print("\n✅ All password security tests passed!")

Salt Demonstration

Here’s a visual demonstration of how salting works:
import bcrypt

password = "gwW$3zHw"  # Same password

# First hash with random salt
salt1 = bcrypt.gensalt()
hash1 = bcrypt.hashpw(password.encode(), salt1)

# Second hash with different random salt
salt2 = bcrypt.gensalt()
hash2 = bcrypt.hashpw(password.encode(), salt2)

print(f"Password: {password}")
print(f"\nSalt 1: {salt1.decode()}")
print(f"Hash 1: {hash1.decode()}")
print(f"\nSalt 2: {salt2.decode()}")
print(f"Hash 2: {hash2.decode()}")
print(f"\nHashes are different: {hash1 != hash2}")
print(f"Both verify correctly: {bcrypt.checkpw(password.encode(), hash1) and bcrypt.checkpw(password.encode(), hash2)}")
Output:
Password: gwW$3zHw

Salt 1: $2b$12$ieYNkQp8QumgedUo30nuPO
Hash 1: $2b$12$ieYNkQp8QumgedUo30nuPOKjQvBXr7y3YGkbTzHwXwH.hVKMz4Coe

Salt 2: $2b$12$97xH7v5J8kL9mNqP2rStUe
Hash 2: $2b$12$97xH7v5J8kL9mNqP2rStUeQ3bYhN8dZxCyVwKjLmOpTqRsWxEyKAi

Hashes are different: True
Both verify correctly: True

References

Build docs developers (and LLMs) love