Dev Hub Solutions

Product studio

Get in touch
5 min readsecurity / hashing / fundamentals

MD5 vs SHA-256 vs Argon2: pick the right hash for the job

MD5 is broken but still useful. SHA-256 is the default for general hashing. Argon2 is for passwords. Mixing them up causes real production bugs.

A code review I did last year had this:

const passwordHash = crypto.createHash('sha256').update(password).digest('hex');

The author thought hashing a password with SHA-256 was secure. It isn't. The reason takes 200 words to explain and the fix takes one line. Both are below.

The three families

Cryptographic hash functions (MD5, SHA-1, SHA-2 family, SHA-3 family). Fast. Designed to produce a fixed-length fingerprint of arbitrary input where finding two inputs that hash the same is computationally hard (collision resistance) and reversing the hash is computationally hard (preimage resistance).

Password hashing functions (Argon2, scrypt, bcrypt, PBKDF2). Deliberately slow. Designed to make brute-force attacks impractical by being expensive to compute, even at scale on GPUs and ASICs.

Message authentication codes (HMAC-SHA256, HMAC-SHA512). Keyed hashes. Produce a fingerprint that requires a shared secret to verify — used for webhook signatures, JWT HS256, anything where you want to prove "this message came from someone who knows the key."

Mixing these up is the source of about 80% of cryptographic bugs in application code.

Cryptographic hashes: when and which

MD5 (1992). Collision-broken since 2004 — practical collision attacks exist. Still fine for:

  • File integrity checksums where the source isn't adversarial (downloading a tarball, the publisher isn't trying to fool you)
  • Cache keys (you control both ends)
  • Content addressing for non-security purposes (deduplication)
  • ETags (servers controlling their own hashes)

Do not use for: digital signatures, certificates, anything where an adversary can choose the input.

SHA-1 (1995). Theoretical collision attacks demonstrated in 2017. Still appears in some legacy protocols (Git uses SHA-1 for object identity, though it's migrating to SHA-256). Same caveats as MD5 for new code: not for anything adversarial.

SHA-256 (2001, part of SHA-2). No practical attacks. The modern default for general-purpose hashing. Use it for:

  • File integrity in adversarial contexts (verifying downloads, signed releases)
  • Content addressing in version control or content-addressed storage
  • Anywhere you would have reached for MD5 in 2010 but need actual security

SHA-512. Same algorithm family as SHA-256, larger digest (512 bits vs 256). On 64-bit hardware, SHA-512 is faster than SHA-256 because it processes 1024-bit blocks (vs 512-bit). Use when:

  • You want larger digest (more collision resistance)
  • You're on 64-bit hardware and care about throughput
  • You're stuck with a 64-bit platform and SHA-256's 32-bit-block processing is slower

SHA-3 / Keccak. Different mathematical foundation than SHA-2. Use when you specifically need algorithm diversity (defense against future attacks on SHA-2's Merkle-Damgård construction), or when interfacing with Ethereum or other ecosystems that standardised on Keccak.

For everything else, SHA-256 is the right call.

Password hashes: the actual point

A password hash needs to be slow, salted, and memory-hard. Here's why each property matters:

Slow: An attacker with a leaked database wants to test billions of guesses per second. If your hash function takes 100ms per call, the attacker can test 10 per second per CPU. SHA-256 takes microseconds per call — millions of guesses per second per GPU. The fast hash is the attacker's friend.

Salted: A unique random salt per password means precomputed rainbow tables don't work. Each password requires its own attack. Even two users with the same password get different hashes.

Memory-hard: Modern attackers use GPUs and ASICs, which have lots of parallelism but limited memory per core. A memory-hard function (Argon2, scrypt) requires significant memory per evaluation, making GPU/ASIC attacks much more expensive.

Argon2id (the variant you want — it resists both side-channel and GPU attacks) is the OWASP 2024 recommendation. Reasonable parameters: 19 MiB memory, 2 iterations, 1 parallelism for an interactive login.

import { hash, verify } from '@node-rs/argon2';

const passwordHash = await hash(password, {
  memoryCost: 19456,    // ~19 MiB
  timeCost: 2,
  outputLen: 32,
  parallelism: 1,
});

const isValid = await verify(passwordHash, password);

Each hash call takes ~100ms, ~19 MiB. An attacker with a GPU can do maybe 100 of these per second, vs millions of SHA-256 calls. That's the gap.

bcrypt is the older alternative, still safe to use, but tuneable only on time (not memory). scrypt is also fine — Argon2id is just the modern preference.

PBKDF2 is OK as a last resort (FIPS 140-2 compliance, very old systems) but should not be your first choice in 2026.

HMAC: when you need to prove authorship

If your tool needs to verify "did this webhook actually come from Stripe?", that's HMAC's job. The webhook source signs the payload with a shared secret: HMAC-SHA256(secret, payload). The receiver verifies by re-computing the HMAC with the same secret. If they match, the message is authentic.

Three things to get right:

  1. Constant-time comparison. a === b is variable-time and leaks information via timing side channels. Use crypto.timingSafeEqual(a, b) in Node, hmac.compare_digest in Python.

  2. Sign the raw bytes, not the parsed object. If you parse JSON first and re-serialise to verify, you'll get different bytes than the sender signed (whitespace, key order). Always sign and verify the exact bytes.

  3. Include timestamp + replay protection. HMAC verifies authenticity, not freshness. Include a timestamp in the signed payload and reject signatures older than ~5 minutes.

Common bugs

Bug: sha256(password) for password storage. Fix: Argon2id.

Bug: == for comparing HMAC outputs. Fix: crypto.timingSafeEqual.

Bug: Hash of echo "foo" doesn't match hash of "foo". Cause: echo adds a newline. Fix: echo -n or printf.

Bug: SHA-256 of two different strings produces the same hash. Cause: encoding mismatch (UTF-8 vs UTF-16 vs Latin-1). Fix: hash bytes, not strings; pick an encoding and stick to it.

Tools

Our hash generator computes MD5, SHA-1, SHA-256, SHA-384, and SHA-512 in your browser using the Web Crypto API. The MD5 path uses js-md5 since the Web Crypto spec deliberately doesn't expose MD5 (it's not considered safe enough to be a first-class browser primitive).