How to Hash Passwords Securely

How-To Guide

How to Hash Passwords Securely

The difference between "encrypted" and actually secure

TL;DR

TL;DR

Use bcrypt (cost factor 10-12) or Argon2id. Never MD5, SHA1, or SHA256 for passwords; modern GPUs crack those in seconds. Hash server-side at registration, verify with the library's compare function at login. Plaintext passwords in your database are an instant breach.

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. They're built for checksums and file integrity, not secrets. A modern GPU can test billions of MD5 hashes per second, turning a database breach into a full password dump in hours.

bcrypt is the most widely used password hashing algorithm. It's deliberately slow, and it generates a unique random salt per hash automatically so you don't have to manage salts yourself.

1

Install bcrypt

npm install bcrypt
npm install @types/bcrypt --save-dev  # TypeScript
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 won the Password Hashing Competition in 2015 and is the current OWASP recommendation for new systems. For most apps, bcrypt is fine. If you're handling health records, financial data, or a large user base, start here.

1

Install argon2

npm install argon2
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?

FactorbcryptArgon2id
SecurityExcellentBest available
EcosystemWidely supportedGrowing support
InstallationNative bindingsNative bindings
RecommendationMost appsHigh-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

bcrypt embeds a unique random salt in each hash output, so the same password hashes to a different string every time. Direct comparison will never match.

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
How-To Guides

How to Hash Passwords Securely