How to Hash Passwords Securely

Share
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.

bcrypt is the most widely used password hashing algorithm. It's deliberately slow and includes a salt automatically.

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 is the winner of the Password Hashing Competition and offers the best security. Use it for high-security applications.

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

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