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