How to Hash Passwords in Python with BCrypt
Storing passwords in plain text is one of the most dangerous security mistakes in software development. If an attacker gains access to your database, every user's password is immediately compromised. The solution is password hashing, a one-way transformation that converts passwords into irreversible, fixed-length strings.
BCrypt is one of the most trusted password hashing algorithms, specifically designed to be slow and resistant to brute-force attacks. This guide covers how to hash and verify passwords in Python using the bcrypt library.
Why BCrypt?โ
Not all hashing algorithms are suitable for passwords. General-purpose hash functions like MD5 and SHA-256 are designed to be fast, which actually makes them bad for password storage: an attacker can try billions of guesses per second.
BCrypt solves with several key features:
- Intentionally slow: It is computationally expensive, making brute-force attacks impractical.
- Built-in salting: It automatically generates and embeds a unique random salt in each hash, preventing rainbow table attacks.
- Configurable cost factor: You can increase the work factor as hardware gets faster, keeping the algorithm future-proof.
- Same password, different hash: Thanks to the random salt, hashing the same password twice produces different results.
Installationโ
Install the bcrypt library using pip:
pip install bcrypt
Hashing a Passwordโ
To hash a password, you need two steps: generate a salt and then compute the hash.
import bcrypt
# The password to hash
password = "my_secure_password"
# Convert the password string to bytes
password_bytes = password.encode('utf-8')
# Generate a salt (random data added to the password before hashing)
salt = bcrypt.gensalt()
# Hash the password with the salt
hashed = bcrypt.hashpw(password_bytes, salt)
print("Salt:", salt)
print("Hash:", hashed)
Output (varies each run due to random salt):
Salt: b'$2b$12$YcGl/EmtUvPQMHMbQuvh5O'
Hash: b'$2b$12$YcGl/EmtUvPQMHMbQuvh5Ou4wXjuGhWmzeu9CN7VBazVbZmYWE13K'
Understanding the Hash Formatโ
The BCrypt hash contains all the information needed to verify a password later:
$2b$12$LJ3m4ys3Lf.mQkWbgyeshOgOBbJGmZ2xTfEBirNaJHrAhcqgni6
โ โ โ โ
โ โ โ โโโ Hashed password
โ โ โโโ 22-character salt
โ โโโ Cost factor (2^12 = 4096 iterations)
โโโ Algorithm identifier (2b = BCrypt)
Because a new random salt is generated each time, hashing the same password produces a different hash on every call:
import bcrypt
password = b"hello123"
hash1 = bcrypt.hashpw(password, bcrypt.gensalt())
hash2 = bcrypt.hashpw(password, bcrypt.gensalt())
print(hash1) # b'$2b$12$abc...'
print(hash2) # b'$2b$12$xyz...' (Different!)
Output:
b'$2b$12$etO.0BLu2zoGyP5jieHkGOFImEmoFa2D1B03LdP8C.gJjJ5IjJKmS'
b'$2b$12$QWPy1CAzFQ92V/9bvUNs7OrdVcDViLvClLEz3TAxhIjOAi2vEdyS6'
This is a security feature. Even if two users have the same password, their hashes will be different.
Verifying a Passwordโ
To check if a user-entered password matches the stored hash, use bcrypt.checkpw(). This function extracts the salt from the stored hash and re-hashes the input to compare:
import bcrypt
# --- Registration: Hash and store the password ---
password = "my_secure_password"
password_bytes = password.encode('utf-8')
stored_hash = bcrypt.hashpw(password_bytes, bcrypt.gensalt())
# --- Login: Verify the entered password ---
entered_password = "my_secure_password"
entered_bytes = entered_password.encode('utf-8')
if bcrypt.checkpw(entered_bytes, stored_hash):
print("โ
Password matches! Access granted.")
else:
print("โ Invalid password. Access denied.")
Output:
โ
Password matches! Access granted.
When Passwords Don't Matchโ
import bcrypt
password = "correct_password"
stored_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
# User enters wrong password
wrong_password = "wrong_password"
if bcrypt.checkpw(wrong_password.encode('utf-8'), stored_hash):
print("โ
Password matches!")
else:
print("โ Invalid password.")
Output:
โ Invalid password.
Adjusting the Cost Factorโ
The cost factor (also called work factor or rounds) controls how computationally expensive the hashing is. The default is 12, meaning 2ยนยฒ = 4,096 iterations. Higher values make hashing slower, which increases security but also increases login time.
import bcrypt
import time
password = b"test_password"
for rounds in [10, 12, 14]:
salt = bcrypt.gensalt(rounds=rounds)
start = time.perf_counter()
hashed = bcrypt.hashpw(password, salt)
elapsed = time.perf_counter() - start
print(f"Rounds: {rounds} | Time: {elapsed:.3f}s")
Output (approximate, varies by hardware):
Rounds: 10 | Time: 0.068s
Rounds: 12 | Time: 0.267s
Rounds: 14 | Time: 1.065s
Each increment doubles the computation time. Choose a value that makes hashing take approximately 250msโ500ms on your server:
| Rounds | Approximate Time | Recommendation |
|---|---|---|
| 10 | ~70ms | Minimum for production |
| 12 | ~250ms | Good default |
| 14 | ~1s | High security |
| 16 | ~4s | Too slow for most applications |
You can increase the rounds over time as hardware improves, without needing to re-hash existing passwords immediately.
Complete Example: Registration and Login Systemโ
Here's a practical example that simulates user registration and login:
import bcrypt
def register_user(password: str) -> bytes:
"""Hash a password for storage during registration."""
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed
def verify_login(entered_password: str, stored_hash: bytes) -> bool:
"""Verify an entered password against the stored hash."""
entered_bytes = entered_password.encode('utf-8')
return bcrypt.checkpw(entered_bytes, stored_hash)
# --- Simulate user registration ---
user_password = "S3cur3P@ssw0rd!"
stored_hash = register_user(user_password)
print(f"Stored hash: {stored_hash}\n")
# --- Simulate login attempts ---
login_attempts = [
("S3cur3P@ssw0rd!", "correct password"),
("wrongpassword", "wrong password"),
("S3cur3P@ssw0rd", "missing exclamation mark"),
]
for attempt, description in login_attempts:
result = verify_login(attempt, stored_hash)
status = "โ
Granted" if result else "โ Denied"
print(f" {status}: '{description}'")
Output:
Stored hash: b'$2b$12$q2ghLgM.91X5zcIlSTYxfuwSzIReX3/i/MVw5ODew/85ueWSSJhYy'
โ
Granted: 'correct password'
โ Denied: 'wrong password'
โ Denied: 'missing exclamation mark'
Security Best Practicesโ
Never store passwords in plain text:
# INCORRECT: plain text storage
users_db = {"alice": "password123"}
Never use MD5 or SHA for passwords:
import hashlib
# INCORRECT: too fast, no salt
hashed = hashlib.sha256(b"password").hexdigest()
Never create your own salt manually:
# INCORRECT: predictable salt
salt = b"my_fixed_salt"
Always use bcrypt with auto-generated salts:
import bcrypt
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
Additional Guidelinesโ
- Never log passwords: Not even during debugging.
- Use HTTPS: Ensure passwords are encrypted in transit.
- Enforce strong passwords: Set minimum length and complexity requirements.
- Rate-limit login attempts: Prevent brute-force attacks at the application level.
- Store only the hash: Never store the password or the salt separately since bcrypt embeds the salt in the hash.
BCrypt vs Other Hashing Algorithmsโ
| Algorithm | Purpose | Speed | Built-in Salt | Recommended for Passwords? |
|---|---|---|---|---|
| BCrypt | Password hashing | Slow (tunable) | โ | โ Yes |
| Argon2 | Password hashing | Slow (tunable) | โ | โ Yes (newer standard) |
| scrypt | Password hashing | Slow (tunable) | โ | โ Yes |
| SHA-256 | General hashing | Fast | โ | โ No |
| MD5 | General hashing | Very fast | โ | โ No |
Conclusionโ
BCrypt is a battle-tested, industry-standard algorithm for password hashing in Python. It's intentionally slow, automatically handles salting, and allows you to tune the cost factor as hardware evolves.
Always use bcrypt.hashpw() for hashing and bcrypt.checkpw() for verification; never try to compare hashes manually.
By following these practices, you ensure that even if your database is compromised, user passwords remain protected.