Skip to content

CWE-256: Plaintext Storage of a Password

Overview

Storing passwords in plaintext (database, files, configuration, logs) exposes all user credentials if storage is compromised. Passwords must be hashed with strong algorithms (bcrypt, Argon2, PBKDF2) using salts, making them computationally infeasible to reverse even if the database is stolen.

OWASP Classification

A06:2025 - Insecure Design

Risk

Critical: Plaintext passwords enable mass account compromise if database breached, credential stuffing across sites (password reuse), immediate access for attackers (no cracking needed), compliance violations (PCI-DSS, GDPR, HIPAA), and complete loss of confidentiality for all user accounts.

Remediation Steps

Core principle: Store passwords only using strong, salted, slow hashing (e.g., Argon2/bcrypt/scrypt) and never in reversible form.

Locate Plaintext Password Storage

When reviewing security scan results:

  • Check database schema: Look for VARCHAR password columns (should be hashed)
  • Review configuration files: Find passwords in .properties, .env, config files
  • Search codebase for logging: Look for password variables being logged
  • Check cookies/session storage: Find passwords stored in client-side storage
  • Review API responses: Ensure passwords never returned in responses

Vulnerable patterns:

-- Plaintext password column

CREATE TABLE users (
    id INT,
    username VARCHAR(50),
    password VARCHAR(50)  -- PLAINTEXT!
);

INSERT INTO users VALUES (1, 'admin', 'admin123');  -- STORED IN PLAIN TEXT!

Use Strong Password Hashing (Primary Defense)

import bcrypt
import hashlib
from argon2 import PasswordHasher

# VULNERABLE - plaintext storage
def create_user_bad(username, password):
    # NEVER DO THIS!
    db.execute(
        "INSERT INTO users (username, password) VALUES (?, ?)",
        (username, password)  # Plaintext password!
    )

# VULNERABLE - weak hashing (MD5, SHA-256)
def create_user_weak(username, password):
    # MD5/SHA-256 are too fast - can be cracked quickly!
    password_hash = hashlib.md5(password.encode()).hexdigest()
    # or
    password_hash = hashlib.sha256(password.encode()).hexdigest()

    db.execute(
        "INSERT INTO users (username, password_hash) VALUES (?, ?)",
        (username, password_hash)
    )

# SECURE - bcrypt with proper cost factor
def create_user_bcrypt(username, password):
    # Generate salt and hash password
    # Cost factor 12 = 2^12 = 4096 iterations
    password_hash = bcrypt.hashpw(
        password.encode('utf-8'),
        bcrypt.gensalt(rounds=12)  # Adjust based on your hardware
    )

    db.execute(
        "INSERT INTO users (username, password_hash) VALUES (?, ?)",
        (username, password_hash)
    )

# Verify password
def verify_password_bcrypt(username, password):
    user = db.execute(
        "SELECT password_hash FROM users WHERE username = ?",
        (username,)
    ).fetchone()

    if not user:
        # Timing attack mitigation - always hash even if user not found
        bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
        return False

    return bcrypt.checkpw(
        password.encode('utf-8'),
        user['password_hash']
    )

# BEST - Argon2id (most secure)
def create_user_argon2(username, password):
    ph = PasswordHasher(
        time_cost=3,        # Iterations
        memory_cost=65536,  # 64 MB
        parallelism=4,      # Threads
        hash_len=32,        # Output hash length
        salt_len=16         # Salt length
    )

    password_hash = ph.hash(password)

    db.execute(
        "INSERT INTO users (username, password_hash) VALUES (?, ?)",
        (username, password_hash)
    )

def verify_password_argon2(username, password):
    ph = PasswordHasher()

    user = db.execute(
        "SELECT password_hash FROM users WHERE username = ?",
        (username,)
    ).fetchone()

    if not user:
        return False

    try:
        ph.verify(user['password_hash'], password)

        # Check if rehashing needed (cost factor changed)
        if ph.check_needs_rehash(user['password_hash']):
            new_hash = ph.hash(password)
            db.execute(
                "UPDATE users SET password_hash = ? WHERE username = ?",
                (new_hash, username)
            )

        return True
    except:
        return False

Java with bcrypt:

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

public class UserService {
    private final PasswordEncoder passwordEncoder = 
        new BCryptPasswordEncoder(12);  // Cost factor 12

    // VULNERABLE - plaintext storage
    public void createUserBad(String username, String password) {
        jdbcTemplate.update(
            "INSERT INTO users (username, password) VALUES (?, ?)",
            username, password  // PLAINTEXT!
        );
    }

    // SECURE - bcrypt hashing
    public void createUserSafe(String username, String password) {
        String passwordHash = passwordEncoder.encode(password);

        jdbcTemplate.update(
            "INSERT INTO users (username, password_hash) VALUES (?, ?)",
            username, passwordHash
        );
    }

    public boolean verifyPassword(String username, String password) {
        String storedHash = jdbcTemplate.queryForObject(
            "SELECT password_hash FROM users WHERE username = ?",
            String.class,
            username
        );

        if (storedHash == null) {
            // Timing attack mitigation
            passwordEncoder.encode(password);
            return false;
        }

        return passwordEncoder.matches(password, storedHash);
    }
}

Never Log or Expose Passwords

const bcrypt = require('bcrypt');
const winston = require('winston');

// VULNERABLE - logging password
app.post('/register', async (req, res) => {
    const { username, password } = req.body;

    // BUG: Password logged in plaintext!
    logger.info(`Creating user: ${username}, password: ${password}`);

    const hash = await bcrypt.hash(password, 12);
    await db.createUser(username, hash);
});

// SECURE - never log passwords
app.post('/register', async (req, res) => {
    const { username, password } = req.body;

    // Log username only
    logger.info(`Creating user: ${username}`);

    // Hash password immediately
    const hash = await bcrypt.hash(password, 12);

    // Clear password from memory (optional)
    req.body.password = null;

    await db.createUser(username, hash);

    // Never return password in response
    res.json({ username, created: true });
});

// VULNERABLE - password in GET request
app.get('/login', (req, res) => {
    const { username, password } = req.query;  // URL parameters!
    // Password visible in logs, browser history, referrer headers!
});

// SECURE - password in POST body only
app.post('/login', async (req, res) => {
    const { username, password } = req.body;  // Request body, not URL

    const user = await db.getUserByUsername(username);

    if (!user || !await bcrypt.compare(password, user.password_hash)) {
        return res.status(401).json({ error: 'Invalid credentials' });
    }

    res.json({ token: generateToken(user) });
});

Migrate Existing Plaintext Passwords

# Migration script to hash existing plaintext passwords
import bcrypt
import sqlite3

def migrate_passwords():
    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()

    # Add new column for hashed passwords
    cursor.execute("""
        ALTER TABLE users 
        ADD COLUMN password_hash BLOB
    """)

    # Get all users with plaintext passwords
    cursor.execute("""
        SELECT id, password 
        FROM users 
        WHERE password_hash IS NULL
    """)

    users = cursor.fetchall()

    print(f"Migrating {len(users)} passwords...")

    for user_id, plaintext_password in users:
        # Hash the plaintext password
        password_hash = bcrypt.hashpw(
            plaintext_password.encode('utf-8'),
            bcrypt.gensalt(rounds=12)
        )

        # Update user record
        cursor.execute("""
            UPDATE users 
            SET password_hash = ?
            WHERE id = ?
        """, (password_hash, user_id))

    conn.commit()

    # Optional: Drop plaintext password column after migration
    # cursor.execute("ALTER TABLE users DROP COLUMN password")

    print("Migration complete!")
    conn.close()

# Force password reset for all users (recommended)
def force_password_reset():
    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()

    cursor.execute("""
        UPDATE users
        SET password_reset_required = 1
    """)

    conn.commit()
    conn.close()

Implement Secure Password Reset

import secrets
import hashlib
from datetime import datetime, timedelta

# VULNERABLE - sending password via email
def reset_password_bad(email):
    user = db.get_user_by_email(email)

    # Generate new password
    new_password = generate_random_password()

    # BUG: Emailing password in plaintext!
    send_email(email, f"Your new password is: {new_password}")

    # Store plaintext password
    db.update_password(user.id, new_password)

# SECURE - using time-limited reset tokens
def reset_password_safe(email):
    user = db.get_user_by_email(email)

    if not user:
        # Don't reveal if email exists
        return

    # Generate cryptographically secure token
    reset_token = secrets.token_urlsafe(32)

    # Hash token before storing
    token_hash = hashlib.sha256(reset_token.encode()).hexdigest()

    # Store with expiration (1 hour)
    expiration = datetime.utcnow() + timedelta(hours=1)
    db.store_reset_token(user.id, token_hash, expiration)

    # Send reset link (not password!)
    reset_url = f"https://example.com/reset?token={reset_token}"
    send_email(email, f"Reset your password: {reset_url}")

def complete_password_reset(token, new_password):
    # Hash provided token
    token_hash = hashlib.sha256(token.encode()).hexdigest()

    # Verify token exists and not expired
    reset_record = db.get_reset_token(token_hash)

    if not reset_record or reset_record.expiration < datetime.utcnow():
        return False

    # Hash new password
    password_hash = bcrypt.hashpw(
        new_password.encode('utf-8'),
        bcrypt.gensalt(rounds=12)
    )

    # Update password
    db.update_password_hash(reset_record.user_id, password_hash)

    # Invalidate reset token
    db.delete_reset_token(token_hash)

    return True

Test Password Security

import bcrypt
import unittest

class PasswordSecurityTest(unittest.TestCase):

    def test_password_is_hashed(self):
        """Verify passwords are hashed, not stored in plaintext"""
        password = "mySecurePassword123!"

        # Create user
        create_user("testuser", password)

        # Retrieve from database
        user = db.get_user("testuser")

        # Password should NOT be plaintext
        self.assertNotEqual(user.password_hash, password)

        # Should be bcrypt hash (starts with $2b$)
        self.assertTrue(user.password_hash.startswith(b'$2b$'))

    def test_password_verification(self):
        """Verify password verification works correctly"""
        password = "correctPassword"

        create_user("testuser", password)

        # Correct password should verify
        self.assertTrue(verify_password("testuser", password))

        # Wrong password should not verify
        self.assertFalse(verify_password("testuser", "wrongPassword"))

    def test_no_password_in_logs(self):
        """Verify passwords don't appear in logs"""
        with self.assertLogs(level='INFO') as logs:
            create_user("testuser", "secretPassword123")

            # Check logs don't contain password
            for log in logs.output:
                self.assertNotIn("secretPassword123", log)

    def test_unique_salts(self):
        """Verify each password gets unique salt"""
        password = "samePassword"

        hash1 = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
        hash2 = bcrypt.hashpw(password.encode(), bcrypt.gensalt())

        # Same password should produce different hashes
        self.assertNotEqual(hash1, hash2)

Common Vulnerable Patterns

  • Storing password column as VARCHAR
  • Logging passwords in debug output
  • Passwords in configuration files
  • Using encryption instead of hashing
  • Using fast hashes (MD5, SHA-1, SHA-256)

Security Checklist

  • All passwords hashed with bcrypt/Argon2/PBKDF2
  • Cost factor configured (bcrypt ≥12, PBKDF2 ≥600,000)
  • Unique salt per password (automatic with bcrypt/Argon2)
  • No passwords in logs, config files, or URLs
  • Passwords transmitted over HTTPS only
  • Password reset uses tokens, not passwords
  • Tests verify hashing, no plaintext storage
  • Migration plan for existing plaintext passwords

Additional Resources