How-To Guide
How to Hash Passwords Securely
The difference between "encrypted" and actually secure
TL;DR
TL;DR
Use bcrypt with a cost factor of 10-12, or Argon2id if you need maximum security. Never use MD5, SHA1, or SHA256 for passwords. Hash on registration, compare on login. Never store plaintext passwords.
Why Regular Hashing Isn't Enough
Never Use These for Passwords
// INSECURE: These can be cracked in seconds
const crypto = require('crypto');
// MD5 - completely broken
crypto.createHash('md5').update(password).digest('hex');
// SHA1 - also broken
crypto.createHash('sha1').update(password).digest('hex');
// SHA256 - too fast, easily brute-forced
crypto.createHash('sha256').update(password).digest('hex');
These algorithms are fast by design. An attacker can try billions of passwords per second.
Option 1: bcrypt (Recommended)
bcrypt is the most widely used password hashing algorithm. It's deliberately slow and includes a salt automatically.
2
Hash password on registration
import bcrypt from 'bcrypt';
async function registerUser(email: string, password: string) {
// Cost factor of 10-12 is recommended
// Higher = slower = more secure, but uses more CPU
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Store hashedPassword in database (NOT the original password)
await db.user.create({
data: {
email,
password: hashedPassword, // e.g., "$2b$10$N9qo8..."
}
});
}
3
Verify password on login
async function loginUser(email: string, password: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
throw new Error('Invalid credentials');
}
// Compare submitted password with stored hash
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
throw new Error('Invalid credentials');
}
// Password is correct, create session
return createSession(user);
}
Option 2: Argon2 (Maximum Security)
Argon2id is the winner of the Password Hashing Competition and offers the best security. Use it for high-security applications.
2
Hash and verify
import argon2 from 'argon2';
// Hash password
async function hashPassword(password: string) {
return argon2.hash(password, {
type: argon2.argon2id, // Recommended variant
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
});
}
// Verify password
async function verifyPassword(hash: string, password: string) {
return argon2.verify(hash, password);
}
// Usage
const hash = await hashPassword('userPassword123');
const isValid = await verifyPassword(hash, 'userPassword123');
Complete Example: Next.js API Route
// app/api/auth/register/route.ts
import bcrypt from 'bcrypt';
import { prisma } from '@/lib/prisma';
export async function POST(request: Request) {
const { email, password } = await request.json();
// Validate input
if (!email || !password) {
return Response.json(
{ error: 'Email and password required' },
{ status: 400 }
);
}
if (password.length < 8) {
return Response.json(
{ error: 'Password must be at least 8 characters' },
{ status: 400 }
);
}
// Check if user exists
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
return Response.json(
{ error: 'Email already registered' },
{ status: 409 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
},
select: {
id: true,
email: true,
// Never return the password hash
},
});
return Response.json(user, { status: 201 });
}
bcrypt vs Argon2: Which to Choose?
| Factor | bcrypt | Argon2id |
|---|---|---|
| Security | Excellent | Best available |
| Ecosystem | Widely supported | Growing support |
| Installation | Native bindings | Native bindings |
| Recommendation | Most apps | High-security apps |
Common Mistakes
Hashing on the Client Side
Don't Do This
// WRONG: Client-side hashing
const hash = await bcrypt.hash(password, 10);
await fetch('/api/register', { body: JSON.stringify({ hash }) });
// The hash becomes the password! Attacker can use it directly.
Using a Fixed Salt
Don't Do This
// WRONG: Same salt for all passwords
const salt = 'mysecuresalt123';
const hash = crypto.createHash('sha256').update(salt + password).digest('hex');
// bcrypt generates a unique salt automatically - use it!
Comparing Hashes Directly
Don't Do This
// WRONG: Direct comparison
if (storedHash === bcrypt.hashSync(password, 10)) {
// This will never match! Each hash is unique.
}
// RIGHT: Use the compare function
if (await bcrypt.compare(password, storedHash)) {
// This works correctly
}
Migrating from Insecure Hashing
If you have existing MD5/SHA hashes, migrate gradually:
async function loginWithMigration(email: string, password: string) {
const user = await db.user.findUnique({ where: { email } });
if (user.password.startsWith('$2b$')) {
// Already bcrypt
return bcrypt.compare(password, user.password);
}
// Legacy MD5 hash
const md5Hash = crypto.createHash('md5').update(password).digest('hex');
if (md5Hash === user.password) {
// Upgrade to bcrypt
const newHash = await bcrypt.hash(password, 10);
await db.user.update({
where: { id: user.id },
data: { password: newHash },
});
return true;
}
return false;
}
Password Security Checklist
- Use bcrypt (10+ rounds) or Argon2id
- Hash on the server, never the client
- Use the library's compare function
- Never log or expose password hashes
- Require minimum 8 character passwords
- Consider checking against breached password lists