Password Hashing Done Right: Argon2id, bcrypt, and the Mistakes That Still Get Apps Breached

Why SHA-256 and unsalted MD5 are not password hashes, how Argon2id and bcrypt actually work, and correct implementations in Python, JavaScript, and Go.

Every few months another breach disclosure includes the phrase “passwords were hashed using SHA-256.” That sentence should trigger alarm, not relief. SHA-256 is a general-purpose cryptographic hash designed to be fast — exactly the wrong property for password storage. A single consumer GPU can compute billions of SHA-256 hashes per second, which means an attacker with a stolen hash database can brute-force short or common passwords in minutes. Password storage needs a hash function designed to be slow and memory-hungry, so the same guessing attack that runs in minutes against SHA-256 takes years against a proper password hashing algorithm.

This is OWASP’s Cryptographic Failures category (A02:2021), and password storage is one of the most common ways teams land in it.

Why Fast Hashes Fail and Salting Alone Isn’t Enough

Two mistakes usually appear together:

  1. Using a fast general-purpose hash (MD5, SHA-1, SHA-256, SHA-512) instead of a password-hashing KDF (key derivation function).
  2. Skipping or misusing salts, letting attackers precompute rainbow tables or crack every user’s password with one shared computation.

Salting alone does not fix problem #1. A salted SHA-256 hash still lets an attacker throw a GPU cluster at each individual hash and try billions of guesses per second per hash. What actually slows an attacker down is a function that is deliberately expensive to compute — in CPU time, memory, or both — so that the cost of testing one guess is high even with specialized hardware.

The Algorithms That Matter

Argon2id is the current OWASP-recommended default. It won the Password Hashing Competition and combines resistance to GPU cracking (via tunable memory cost) with resistance to side-channel and time-memory trade-off attacks (the “id” variant mixes Argon2i’s side-channel resistance with Argon2d’s GPU resistance). OWASP’s Password Storage Cheat Sheet recommends a minimum configuration of memory=19456 KiB (19 MiB), iterations=2, parallelism=1, scaled up if your server budget allows.

bcrypt is older (1999) but still acceptable for legacy systems, with a minimum work factor of 10. Its main limitation: bcrypt silently truncates passwords longer than 72 bytes, so extremely long passphrases collide unexpectedly unless you pre-hash them.

scrypt is memory-hard like Argon2id; OWASP’s fallback recommendation is N=2^17 (128 MiB), r=8, p=1 when Argon2id isn’t available.

PBKDF2-HMAC-SHA256 should only be used when FIPS-140 compliance forces your hand, with a minimum of 600,000 iterations — and even then it’s the weakest of the four against GPU attacks because it isn’t memory-hard.

Correct Implementation

Python (argon2-cffi)

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)

def hash_password(password: str) -> str:
    return ph.hash(password)

def verify_password(stored_hash: str, password: str) -> bool:
    try:
        ph.verify(stored_hash, password)
        return True
    except VerifyMismatchError:
        return False

argon2-cffi embeds the salt, algorithm variant, and parameters inside the returned hash string, so verification doesn’t require you to store them separately.

JavaScript / Node.js (argon2)

const argon2 = require('argon2');

async function hashPassword(password) {
  return argon2.hash(password, {
    type: argon2.argon2id,
    memoryCost: 19456, // KiB
    timeCost: 2,
    parallelism: 1,
  });
}

async function verifyPassword(storedHash, password) {
  try {
    return await argon2.verify(storedHash, password);
  } catch {
    return false; // malformed hash, not a match
  }
}

Go (bcrypt via golang.org/x/crypto)

Go’s ecosystem doesn’t ship a first-party Argon2id password helper as mature as argon2-cffi or node-argon2, so bcrypt from the standard extended library is a common, safe choice — as long as you truncate or pre-hash long inputs and use a work factor of at least 12:

package auth

import "golang.org/x/crypto/bcrypt"

const cost = 12

func HashPassword(password string) (string, error) {
    hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
    if err != nil {
        return "", err
    }
    return string(hash), nil
}

func VerifyPassword(storedHash, password string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password))
    return err == nil
}

bcrypt.CompareHashAndPassword runs in constant time relative to the comparison itself, so it doesn’t introduce a timing side channel on top of the KDF’s inherent cost.

Migrating an Existing SHA-256 or MD5 Deployment

You cannot “re-hash” a fast hash into a slow one directly — you don’t have the plaintext anymore. The standard approach is lazy migration:

  1. Keep verifying against the old hash on login.
  2. On successful login, re-hash the plaintext password (which you have at that moment) with Argon2id and overwrite the stored value.
  3. Track a hash_version or algorithm column so verification logic knows which check to run.
  4. Force a reset for accounts that haven’t logged in after a defined grace period, since you’ll never get another chance to capture their plaintext.

Checklist

  • Use Argon2id with at least OWASP’s minimum parameters; fall back to bcrypt (cost ≥ 12) or scrypt only when Argon2id isn’t available in your stack.
  • Never use MD5, SHA-1, or unsalted/single-round SHA-256/512 for passwords — they’re fine for file integrity, not credentials.
  • Store the algorithm, version, and cost parameters alongside the hash (most modern libraries do this automatically in the hash string) so you can raise cost factors later without breaking old hashes.
  • Rate-limit and lock out authentication endpoints regardless of hash strength — hashing slows offline cracking, not online guessing.
  • Cap accepted password length in the application layer (e.g., 128 bytes) to prevent hashing-cost denial-of-service from attacker-supplied multi-megabyte “passwords.”

Password hashing is one of the few areas of application security where the fix is almost entirely a library call away. The failure mode isn’t complexity — it’s teams reaching for hashlib.sha256() because it’s already imported, instead of the KDF built for this exact job.