Secure Password Hashing with bcrypt: A Developer’s Guide

Let me tell you something that should make every developer uncomfortable, especially if you’re not using secure password hashing.

In one of my early projects, I had a users table with a simple structure: username and password. No hashing, no encryption, just plain text sitting in the database. If someone had gained access, they would have seen everything instantly. Real usernames. Real passwords.

At the time, it didn’t feel like a big deal. The app was small, not public, and I was focused on getting things working. Security was an afterthought.

That’s the trap. Because databases get breached all the time, and when they do, plain text passwords turn a small mistake into a serious problem.

If you’ve ever stored passwords this way, or you’re not 100% sure your current setup is secure, this guide will walk you through how to fix it properly using secure password hashing with bcrypt in both Python and Node.js.

Why Plain Text Passwords Will Haunt Your Dreams

Before we dive into the how, let’s talk about the why for a second. Because I guarantee someone reading this is thinking “my database is secure, nobody can access it anyway.”

Cool story. Databases get breached. All. The. Time. Remember when LinkedIn got hacked and 6.5 million passwords leaked? Or Adobe with 38 million? Or literally any of the dozens of massive breaches that happen every year?

When your database gets compromised (and notice I said “when,” not “if”), you don’t want attackers to have a nice, organized list of everyone’s actual passwords. That’s why secure password hashing matters so much. Because here’s the thing, people reuse passwords everywhere. That password for their small blog platform? There’s a decent chance it’s the same one they use for their bank.

So when you store passwords in plain text and get breached, you’re not just exposing access to your app. You’re potentially compromising your users’ entire digital lives. Fun times for everyone involved, especially your legal team.

Secure Password Hashing vs. Encryption

Quick terminology check because people mix these up constantly and it matters.

Encryption is reversible. You encrypt something, you can decrypt it back to the original. This is what you use for data you need to read later, like credit card numbers you need to charge, or messages you need to display.

Hashing is one-way. You hash something, and theoretically, you can’t get the original back. It’s like putting your password through a meat grinder, you get something consistent out, but good luck reconstructing the original steak.

For passwords, you want hashing. You never actually need to know what the user’s password is. You just need to verify that what they’re typing in matches what you stored.

When they log in, you hash what they typed and compare it to your stored hash. If they match, cool, they’re in. If not, wrong password buddy.

The Python Side: bcrypt Is Your Friend for Secure Password Hashing.

Python has a bunch of options for password hashing, but I’m going to save you some time: just use bcrypt. It’s specifically designed for passwords, it’s slow (which is good for security, more on that in a bit), and it’s battle-tested.

First, install it:

Bash
pip install bcrypt

Here’s the basic flow for hashing a password:

Python
import bcrypt

def hash_password(password):
    # Convert the password to bytes
    password_bytes = password.encode('utf-8')
    
    # Generate a salt and hash the password
    salt = bcrypt.gensalt()
    hashed = bcrypt.hashpw(password_bytes, salt)
    
    # Return the hash as a string for storage
    return hashed.decode('utf-8')

# When a user signs up
user_password = "super_secret_123"
hashed_password = hash_password(user_password)

# Store hashed_password in your database
print(hashed_password)
# Output looks like: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5NU0TkN/c.ycO

And here’s how you verify a password when they log in:

Python
def verify_password(password, hashed_password):
    password_bytes = password.encode('utf-8')
    hashed_bytes = hashed_password.encode('utf-8')
    
    return bcrypt.checkpw(password_bytes, hashed_bytes)

# When a user tries to log in
attempted_password = "super_secret_123"
stored_hash = "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5NU0TkN/c.ycO"

if verify_password(attempted_password, stored_hash):
    print("Login successful!")
else:
    print("Wrong password!")

One thing that tripped me up initially: bcrypt works with bytes, not strings. That’s why we’re doing all that encoding and decoding. Just roll with it, it’s not complicated once you’ve done it a few times.

What’s This Salt Thing?

Okay so salts. This confused me for way too long when I was learning this stuff, so let me break it down.

A salt is random data that gets mixed into your password before hashing. Every password gets a unique salt, and bcrypt actually stores the salt right in the hash itself (that’s what the first part of that long string is).

Why does this matter? Rainbow tables.

Rainbow tables are basically massive precomputed lists of passwords and their hashes. Without salts, an attacker could hash “password123” once and then search through your entire database for that hash. They’d instantly find everyone using that password.

With salts, every instance of “password123” produces a different hash. Suddenly that precomputed table is useless. The attacker has to brute-force each password individually, which is exponentially more work.

bcrypt handles all of this automatically, which is one reason why it’s so nice to work with.

The JavaScript/Node.js Side

On the Node.js side, the gold standard is also bcrypt. There’s a native npm package, but honestly, I usually use bcryptjs because it’s pure JavaScript and doesn’t require compilation. Makes deployment simpler, especially on platforms like Vercel or Netlify.

Bash
npm install bcryptjs

Here’s the equivalent code:

JavaScript
const bcrypt = require('bcryptjs');

// Hashing a password
async function hashPassword(password) {
    const saltRounds = 12;
    const hashedPassword = await bcrypt.hash(password, saltRounds);
    return hashedPassword;
}

// Using it
const userPassword = "super_secret_123";
hashPassword(userPassword).then(hash => {
    console.log(hash);
    // Store this in your database
});

And verification:

JavaScript
async function verifyPassword(password, hashedPassword) {
    const isMatch = await bcrypt.compare(password, hashedPassword);
    return isMatch;
}

// When user logs in
const attemptedPassword = "super_secret_123";
const storedHash = "$2a$12$Kx8h4kGxBq7/5LeQ2j8z0eKfH9gKjN8qH9j4H9j4H9j4H9j4H9j4H";

verifyPassword(attemptedPassword, storedHash).then(isValid => {
    if (isValid) {
        console.log("Login successful!");
    } else {
        console.log("Wrong password!");
    }
});

Notice these are async functions. bcrypt operations are intentionally slow (that’s a feature, not a bug), so you don’t want to block your event loop. Always use the async versions in production.

Why Slower Is Actually Better

This seems counterintuitive, right? Why would we want our password hashing to be slow?

It’s all about making life harder for attackers. If someone gets your database and tries to brute-force passwords, they need to hash every guess. If hashing is fast, they can try billions of passwords per second. If hashing is slow, they can try thousands.

That saltRounds parameter in the JavaScript example (or the cost factor in bcrypt generally) controls how slow the hashing is. Higher numbers = slower hashing = better security, but also more CPU time on your server.

I usually use 12 rounds. That’s slow enough to frustrate attackers but fast enough that users don’t notice a delay when logging in. If you’re paranoid, go to 14. Don’t go below 10 unless you hate your users’ security.

You can actually benchmark this on your machine:

Python
import bcrypt
import time

password = b"test_password"

for cost in range(10, 15):
    start = time.time()
    bcrypt.hashpw(password, bcrypt.gensalt(cost))
    elapsed = time.time() - start
    print(f"Cost factor {cost}: {elapsed:.3f} seconds")

On my laptop, cost factor 12 takes about 0.15 seconds. That’s perfect, slow enough to protect against brute force, fast enough that users don’t care.

Common Mistakes I’ve Seen (and Made)

Mistake #1: Hashing client-side instead of server-side

I’ve seen people hash passwords in JavaScript before sending them to the server, thinking this is more secure. It’s not. The hash becomes the password at that point. If someone intercepts it, they can just send that hash to log in.

Hash on the server, always. Transmit the plain password over HTTPS (which you better be using), then hash it server-side before storing.

Mistake #2: Using MD5 or SHA-1

These are fast hashing algorithms designed for checksums, not passwords. They’re way too fast, which makes brute-forcing trivial with modern hardware. Also, they’re vulnerable to collision attacks.

Don’t use them. Just don’t. If you’re inheriting a codebase that uses them, make migrating to bcrypt your top priority.

Mistake #3: Not using HTTPS

All the password hashing in the world doesn’t matter if you’re sending passwords over plain HTTP where anyone can sniff them off the network. Use HTTPS. It’s 2025, Let’s Encrypt is free, there’s no excuse.

Mistake #4: Rolling your own crypto

The second you think “I could implement this myself,” stop. Just stop. Cryptography is hard. Really hard. Smart people spend their entire careers on this stuff and still find vulnerabilities.

Use established libraries like bcrypt. They’ve been vetted by experts, tested in production by millions of developers, and had their vulnerabilities found and fixed over years.

Implementing Secure Password Hashing in a Real App

Let me show you a more complete example with a real database. I’ll use SQLAlchemy for Python since that’s what I reach for most often.

Python
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
import bcrypt

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'
db = SQLAlchemy(app)

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    password_hash = db.Column(db.String(200), nullable=False)

@app.route('/signup', methods=['POST'])
def signup():
    data = request.get_json()
    username = data.get('username')
    password = data.get('password')
    
    # Hash the password
    password_bytes = password.encode('utf-8')
    salt = bcrypt.gensalt()
    hashed = bcrypt.hashpw(password_bytes, salt)
    
    # Create new user
    new_user = User(
        username=username,
        password_hash=hashed.decode('utf-8')
    )
    
    db.session.add(new_user)
    db.session.commit()
    
    return jsonify({"message": "User created successfully"}), 201

@app.route('/login', methods=['POST'])
def login():
    data = request.get_json()
    username = data.get('username')
    password = data.get('password')
    
    user = User.query.filter_by(username=username).first()
    
    if not user:
        return jsonify({"error": "Invalid credentials"}), 401
    
    password_bytes = password.encode('utf-8')
    stored_hash = user.password_hash.encode('utf-8')
    
    if bcrypt.checkpw(password_bytes, stored_hash):
        return jsonify({"message": "Login successful"}), 200
    else:
        return jsonify({"error": "Invalid credentials"}), 401

Note that the password field in the database is called password_hash, not password. This is a good practice, it makes it obvious what’s stored there and helps prevent accidents where someone might try to query it expecting a plain-text password.

Also notice that when login fails, we return the same error whether the username doesn’t exist or the password is wrong. This prevents attackers from enumerating valid usernames.

Migrating from Plain Text (If You Absolutely Have To)

If you’re in the unfortunate position of having plain-text passwords in production (hey, no judgment, well, maybe a little judgment, but we’ll fix it), here’s how to migrate:

  1. Add a new column password_hash to your users table
  2. Set up a one-time script that reads each plain-text password, hashes it, and stores it in the new column
  3. Update your login code to check both columns. Hash and compare if there’s a hash, otherwise check plain text AND hash it for next time
  4. After all users have logged in at least once (or after some reasonable time period), remove the plain-text column

This lets you migrate without forcing password resets on everyone, which users hate.

Final Thoughts: Just Use bcrypt

Look, password security is one of those things where you really don’t want to get creative. There are newer algorithms like Argon2 that are technically better in some ways, and I wrote a separate guide on Argon2 password hashing if you want to see why developers are moving toward it. But bcrypt is still proven, widely supported, and more than secure enough for virtually any application.

The important part isn’t picking the absolute perfect algorithm. It’s not storing passwords in plain text like some kind of monster. Use bcrypt, use a cost factor of at least 12, store those hashes in your database, and sleep soundly knowing you’re not about to become a security horror story.

And seriously, use HTTPS. I shouldn’t have to say this, but here we are.

Now go forth and hash responsibly.