Use bcrypt for Password Hashing

Learn to use bcrypt for secure password hashing in Python. Covers core concepts, step-by-step implementation, edge cases, and next steps.

Focus: use hashing for passwords with bcrypt

Sponsored

If you’ve ever stored user passwords in plain text or used a fast hash like MD5 or SHA-256, you’ve built a password database that’s one leaked backup away from a catastrophe. Attackers don’t break into systems by guessing—they steal hash files and run them through GPU-powered cracking tools that can try billions of guesses per second. In this lesson, you’ll learn why password hashing with bcrypt is the industry-standard defense, how it works under the hood, and how to implement it in Python with real, production-ready code. By the end, you’ll be able to replace weak hashing schemes with bcrypt in minutes, and you’ll understand exactly why that move protects your users and your reputation.

The problem this lesson solves

Every application that handles user authentication faces the same fundamental problem: you need to verify a password at login, but you should never store the password itself. If your database is compromised, a plain-text password file gives attackers instant access to every account—and because many users reuse passwords, that breach can cascade into their email, bank, and social media accounts.

Even hashing with a fast algorithm like SHA-256 is dangerously insufficient. Why? Because SHA-256 was designed to be lightning fast—and speed is exactly what attackers want. With modern GPUs, an attacker can try billions of SHA-256 hashes per second, meaning even a strong password like HorseBatteryStaple! could be cracked in hours or days using precomputed rainbow tables or brute force.

The solution is a password hashing algorithm designed to be slow, salted, and resistant to hardware acceleration. That’s where bcrypt comes in. Bcrypt is a cryptographic hash function specifically built for passwords, and it’s been the default choice in frameworks like Django and Rails for years. This lesson walks you through it end-to-end.

Core concept / mental model

Think of a password hash as a one-way trapdoor: you can easily compute the hash from the password, but you can’t reverse the hash back to the password. Bcrypt isn’t just a single hash—it’s a key derivation function that combines the password with a random salt and runs a computationally expensive algorithm many times (called a cost factor).

Here’s the mental model: imagine you have a vault door that takes exactly 0.1 seconds to open. For a legitimate user logging in, 0.1 seconds is imperceptible—they wait a moment and get in. But if an attacker has a stolen password hash and wants to try 10 million guesses, they’ll need 10 million × 0.1 seconds = 11.5 days of compute. That’s the entire point: bcrypt’s slowness is a feature, not a bug.

Key terms you’ll see: - Salt: a random string (typically 16–22 characters) prepended to the password before hashing. It ensures that identical passwords produce different hashes, defeating rainbow tables and cross-account attacks. - Cost factor (or work factor): the number of rounds the algorithm iterates. Increasing this by 1 doubles the time required—so you can scale security as hardware improves. - One-way function: you can compute the hash from the password, but not the password from the hash.

When you store a bcrypt hash, you’re actually storing a string that contains the algorithm identifier, the cost factor, the salt, and the hash itself—all in one. For example:

$2b$12$LJ9k8m7s3FhQv9Xp2Yx0eO5tZ3Wq8rK6nM1bVc4DgEfIhJ1uS2a
  • $2b$ → bcrypt version
  • 12 → cost factor (2^12 rounds)
  • The rest → salt (first 22 chars) + actual hash (31 chars)

This means you don’t need to store the salt separately—it’s embedded in the hash string, which is what makes bcrypt so convenient.

How it works step by step

Let’s break down what happens when you hash a password with bcrypt, step by step:

  1. Generate a random salt – Bcrypt automatically creates a 16-byte salt, which is unique for every password hash. This ensures that two users with the same password get different hashes.
  2. Blend the password and salt – The algorithm mixes the salt into the password using a modified Blowfish cipher, then runs the encryption repeatedly.
  3. Run the expensive rounds – The number of rounds is determined by the cost factor. With cost 12, the algorithm runs 2^12 = 4096 iterations. Each iteration is cryptographically complex, making the whole operation deliberately slow (tens to hundreds of milliseconds on typical hardware).
  4. Format the output – The final 60-character string encodes the algorithm, cost, salt, and resulting hash in a single field.
  5. Verify on login – To check a password, you extract the salt from the stored hash, run the same bcrypt operation with the provided password, and compare the resulting hash to the stored one. Because the salt is stored in the hash, you can always reproduce the exact same computation.

The key takeaway: never write your own password verification logic. Hashing libraries like bcrypt handle salt generation, round management, and secure comparison for you.

Hands-on walkthrough

Enough theory—let’s get your hands dirty. First, install the bcrypt library (it’s the official Python wrapper for the OpenBSD bcrypt implementation):

pip install bcrypt

Now, the simplest possible script: hash a password and verify it.

import bcrypt

password = b"super_secret_p@ss"

# Hash the password with a random salt and a cost factor of 12
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))

print("Stored hash:", hashed.decode())

# Verify a correct password
if bcrypt.checkpw(password, hashed):
    print("Login success: password matches!")
else:
    print("Login failed: incorrect password")

# Verify a wrong password
wrong_password = b"wrong_password"
if bcrypt.checkpw(wrong_password, hashed):
    print("This won't print")
else:
    print("Login failed: incorrect password (as expected)")

Expected output:

Stored hash: $2b$12$LJ9k8m7s3FhQv9Xp2Yx0eO5tZ3Wq8rK6nM1bVc4DgEfIhJ1uS2a
Login success: password matches!
Login failed: incorrect password (as expected)

Building a simple user store

In a real app, you’d store the hash in a database. Here’s a minimal example using a dictionary as a stand-in:

import bcrypt

# Simulated user database
users_db = {}

def register_user(username: str, password: str):
    if username in users_db:
        raise ValueError("User already exists")
    hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
    users_db[username] = hashed.decode()
    print(f"User {username} registered.")

def authenticate_user(username: str, password: str) -> bool:
    stored_hash = users_db.get(username)
    if stored_hash is None:
        return False
    return bcrypt.checkpw(password.encode(), stored_hash.encode())

# Test it
register_user("alice", "correct horse battery staple")
print("Login alice:", authenticate_user("alice", "correct horse battery staple"))
print("Login alice wrong:", authenticate_user("alice", "wrong password"))
print("Login bob nonexistent:", authenticate_user("bob", "anything"))

Expected output:

User alice registered.
Login alice: True
Login alice wrong: False
Login bob nonexistent: False

Notice that authenticate_user returns False for unknown usernames—avoid leaking which usernames exist in your system.

Pro tip: Always call bcrypt.checkpw() with a constant-time comparison internally—the library does this for you. Never compare hashes with ==, because Python’s string comparison can reveal timing information that aids attackers.

Compare options / when to choose what

Bcrypt is great, but it’s not the only password hashing scheme. Here’s how it stacks up against the alternatives:

Algorithm Speed Salt Cost factor Best for Notes
MD5 Extremely fast Optional (often omitted) N/A Legacy systems (avoid!) Trivially crackable, not secure
SHA-256 Very fast Optional (not built-in) N/A Integrity checks, not passwords Vulnerable to GPU brute force
bcrypt Slow by design Built-in random salt Configurable (default 12) Password hashing Industry-standard, widely supported
scrypt Slow, memory-hard Built-in Several parameters Password hashing Also memory-hard, resists ASICs
argon2 Slow, memory-hard Built-in Several parameters Password hashing Winner of Password Hashing Competition, modern alternative

When to choose what:

  • Bcrypt is the safest default for most applications. It’s battle-tested, available in every language, and requires zero configuration beyond a cost factor.
  • Argon2 is the modern winner of the Password Hashing Competition and offers better resistance to GPU cracking due to its memory-hard property. If you’re starting a new project and your framework supports it, Argon2id is a solid choice.
  • Scrypt is a similar memory-hard option, but bcrypt is more common.
  • Never use MD5, SHA-1, or raw SHA-256 for passwords. They’re too fast for attackers to exploit.

For most Python applications, bcrypt is the best balance of simplicity, security, and acceptance. If you’re using Django, you can even swap it in as the default hasher.

Troubleshooting & edge cases

“ValueError: password cannot be longer than 72 bytes”

Bcrypt has a 72-byte input limit (not characters—bytes). In UTF-8, some characters (like emojis) take more than 1 byte, so a 60-character emoji string could exceed 72 bytes. If you hit this, you have two options: pre-hash the password with SHA-256 before passing it to bcrypt (a common pattern), but beware that this can introduce issues (see below). Or, better, enforce a reasonable max length on passwords (e.g., 64 characters) and handle the edge case gracefully.

“UnicodeEncodeError” when hashing

bcrypt expects bytes, not strings. Always encode your password with .encode('utf-8') before calling hashpw or checkpw.

“$2a$” vs “$2b$” prefixes

Older bcrypt versions produced $2a$ hashes. If you’re migrating from an old system, bcrypt library can verify $2a$ hashes, but when generating new ones it uses $2b$ (which fixes a bug in the original implementation). You don’t need to worry unless you’re debugging weird failures with legacy hashes.

Should you pre-hash with SHA-256?

Some developers pre-hash passwords with SHA-256 to bypass the 72-byte limit. This is not recommended unless you fully understand the implications: it can introduce length-extension attack vectors and double-hashing issues. If you must, use HMAC-SHA256 with a key, but the simplest safe approach is to limit password length at the form level.

Timing attacks

Even with bcrypt, if your login endpoint returns “user not found” immediately when a username doesn’t exist, an attacker can enumerate usernames by timing responses. Always run a dummy bcrypt compare when the user isn’t found, so response time is consistent.

What about cost factor?

A cost factor that’s too low (e.g., 8) is fast for both you and attackers. A factor that’s too high (e.g., 16) makes login slow for your users. Start with 12 (the default) and benchmark on your production hardware. As hardware improves, increase the cost factor and re-hash passwords on login if the stored cost is lower.

What you learned & what's next

You’ve just mastered the foundation of secure password storage. Let’s recap what you can now do:

  • Explain why plain-text, MD5, and SHA-256 are all inadequate for passwords.
  • Articulate the mental model of bcrypt: slow, salted, one-way hashing.
  • Implement password hashing and verification with the bcrypt library in Python.
  • Compare bcrypt with alternatives like Argon2 and scrypt.
  • Troubleshoot common pitfalls like the 72-byte limit and encoding errors.

You’ve also practiced building a minimal user registration and login flow—exactly what you’ll expand when you connect to real databases and frameworks.

What’s next? In this Secure development track, you’ve covered hashing. The next lesson will dive into defending against brute-force attacks—rate limiting, lockout policies, and how to layer those on top of the bcrypt hashes you now know how to generate. That’s your next step: build a login endpoint that uses bcrypt and think about how you’d rate-limit it.

Keep this knowledge close—it’s one of the most critical security decisions you’ll make as a developer.

Practice recap

Write a small script that registers two users with the same password (e.g., 'password123') and prints their hashes to verify they differ. Then attempt to log in with a wrong password and confirm the function returns False. As a stretch goal, implement a simple rate limiter that locks an account after 5 failed attempts — you'll use this in the next lesson on brute-force defenses.

Common mistakes

  • Using fast cryptographic hashes like MD5 or SHA-256 for passwords, which attackers can crack in seconds with GPUs.
  • Storing passwords without a unique salt, allowing rainbow-table and cross-account attacks.
  • Forgetting to encode passwords as bytes before calling bcrypt functions, leading to UnicodeEncodeError.
  • Setting an overly low cost factor (e.g., below 10) that makes brute-force attacks too easy on modern hardware.
  • Storing the salt separately instead of relying on bcrypt's built-in embedded salt, adding unnecessary complexity and risk.

Variations

  1. Argon2id — the modern password-hashing algorithm that is memory-hard and resists GPU cracking better than bcrypt.
  2. Scrypt — another memory-hard algorithm, popular in environments where bcrypt isn't available or you need custom memory/tuning parameters.
  3. Pre-hashing with a keyed HMAC-SHA256 before bcrypt to handle long passwords — only use with a secret key and full understanding of trade-offs.

Real-world use cases

  • User authentication in a Django or Flask web app — hash passwords with bcrypt during registration and verify on login.
  • Protecting API keys or database connection strings in a configuration file — bcrypt can hash secrets before storage.
  • Migrating a legacy application from MD5 hashes to bcrypt — re-hash passwords on the next successful login to adopt bcrypt over time.

Key takeaways

  • Never store passwords in plain text or fast hashes like SHA-256; bcrypt's deliberate slowness is its security superpower.
  • Bcrypt automatically includes a unique salt and embeds it in the hash string, so you don't manage salts yourself.
  • Always encode passwords as bytes and respect the 72-byte input limit to avoid crash and security issues.
  • Use bcrypt.checkpw() for secure, constant-time verification — never compare hashes with plain ==.
  • Choose a cost factor of 12 as a starting point and adjust based on your hardware and user experience.
  • Bcrypt is a solid default, but consider Argon2 for new projects; always avoid MD5 and SHA-256 for passwords.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.