Tutorial

Password Hashing Explained: Why SHA-256 Is the Wrong Tool and What to Use Instead

Why fast hashes fail at protecting passwords, what salts and work factors add, and how to choose and tune Argon2id, scrypt, bcrypt or PBKDF2.

·
9 min read
·
AI-assisted

AI tooling disclosure

Generative AI materially contributed to this article.

Human review confirmed by the author.

Password Hashing Explained: Why SHA-256 Is the Wrong Tool and What to Use Instead

Sooner or later, a database leaks. When it happens to yours, the attacker holds everything your login form checks against. What matters then is how long it takes to turn those stored values back into real passwords.

That time depends mostly on one decision: which function you used to hash them. A plain SHA-256 hash can fall in minutes. A well-configured slow hash makes every guess tens of thousands to millions of times more expensive, depending on the algorithm.

This post shows the difference with measured numbers. Then it covers salts, the four algorithms OWASP recommends, how to pick settings, and how to upgrade hashes you already have.

What a password hash is for

Passwords should never be stored in plain text, and they shouldn’t be encrypted either. Encryption is reversible, so whoever steals the key gets every password.

A hash is one-way. To check a login, the server hashes what the user typed and compares it with the stored hash. Nobody can “decrypt” a hash, so an attacker has only one option: guess a password, hash it, and compare.

flowchart TD
    leak["Attacker steals the password database"] --> guess["Pick a candidate password"]
    guess --> hash["Hash it with the user's salt and the same settings"]
    hash --> compare{"Matches the stored hash?"}
    compare -->|"No"| guess
    compare -->|"Yes"| found["Password recovered"]

    style hash fill:#e6f0fa,stroke:#2b7de0,stroke-width:2px,color:#1a1a1a

Everything in this post is about that loop. You can’t stop the attacker from running it, but you can decide how long each turn takes.

Why SHA-256 is the wrong tool

SHA-256 was designed to be fast. That is a virtue for checking files and signatures, and a flaw for passwords: the faster the hash, the more guesses an attacker makes per second. OWASP says plainly that fast hashes such as SHA-256 are not suitable for password storage.

To see how big the gap is, I timed four algorithms on a single CPU core. The script is at the end of this post. These are medians of 12 runs on one core of a small, shared cloud VM. Single runs varied by as much as 3.5×, and yours will differ, but the gap won’t:

Algorithm

Guesses per second

Time per hash

One million guesses

SHA-256

about 2.3 million

under 0.001 ms

under 1 second

PBKDF2-HMAC-SHA256 (600,000 iterations)

7.8

127 ms

1.5 days

scrypt (N=2^17, r=8, p=1)

2.8

357 ms

4.1 days

Argon2id (19 MiB, 2 iterations, 1 lane)

43.9

23 ms

6.3 hours

The SHA-256 row includes Python’s own overhead, so a real cracking tool does far better.

Attackers don’t use one CPU core. A single RTX 5090 running Hashcat reaches about 27.7 billion SHA-256 guesses per second, while bcrypt manages about 305,000 per second, according to Tom’s Hardware’s benchmark.

Hashcat’s bcrypt benchmark uses cost 5, and each extra cost step doubles the work. Real systems use cost 10 or more, so I scaled the benchmark to cost 10 (about 9,500 guesses per second) and cost 12 (about 2,400):

What the attacker tries

SHA-256

bcrypt, cost 10

bcrypt, cost 12

Every 8-character password of lowercase letters and digits

1.7 minutes

9.4 years

37.6 years

Every 8-character password of any printable ASCII character

2.8 days

about 22,000 years

about 88,000 years

A wordlist of 1 million common passwords

36 microseconds

1.7 minutes

7.0 minutes

These are single-GPU estimates, so an attacker with 100 GPUs divides every time by 100.

Real attackers also don’t try every combination. They try likely passwords first: leaked passwords, wordlists and common patterns. A slow hash won’t save a very weak password from a targeted list, but it makes every guess expensive, and that cost adds up across thousands of accounts.

Salts: a unique random value for every password

A salt is a random value stored next to the hash and mixed into the hashing. It isn’t secret. Its job is to make the same password produce a different hash for every user.

Without salts, two users with the same password get the same hash:

Unsalted SHA-256 of 'password123':
  alice ef92b778bafe771e...
  bob   ef92b778bafe771e...

Salted, with a random 16-byte salt per user:
  alice salt 6e1052a4...  hash d2b5b726bdcf6e30...
  bob   salt 7b6c4e96...  hash 175bf39233cc9ef2...

Salts do two jobs, as OWASP explains:

  • They defeat precomputed tables. Lookup tables of known password hashes (rainbow tables) are useless once every hash has its own random salt.

  • They force one-at-a-time cracking. The attacker can’t hash a guess once and compare it against every account. The work grows in direct proportion to the number of accounts.

To put a number on the second point: at bcrypt cost 12, one pass of a 1-million-word list takes about 7 minutes on one GPU. Against 1 million accounts with unique salts, that becomes a million passes, or about 13 years.

You rarely generate salts yourself. Most password-hashing libraries create and store one for you.

Some systems also add a pepper: a secret value shared by all passwords and kept outside the database, for example in a secrets vault. It helps when only the database leaks. It’s optional, and OWASP notes that changing a pepper later forces every affected user to reset their password.

Three ways to make hashing expensive

Good password hashes are slow on purpose, and each one gets there differently:

  • Many iterations (PBKDF2). Repeat a hash function hundreds of thousands of times. It costs CPU time only, which GPUs have in abundance.

  • A tunable CPU cost (bcrypt). A cost setting controls how many rounds run, and each extra step doubles the time. It uses very little memory.

  • Memory hardness (scrypt and Argon2id). Each guess needs a large block of RAM. A GPU with thousands of cores can’t run thousands of guesses at once unless it has memory for every one of them.

The defender pays this price once per login. The attacker pays it for every guess.

Which algorithm to use

OWASP lists four, in order of preference:

Algorithm

Cost settings

OWASP minimum

Best for

Watch out for

Argon2id

Memory, iterations, parallelism

19 MiB, 2 iterations, 1 lane (or 46 MiB with 1 iteration)

New systems

Needs a library, and each concurrent login uses its memory

scrypt

N, r, p

N=2^17 (128 MiB), r=8, p=1

When Argon2id isn’t available

Each hash needs 128 MiB at this setting

bcrypt

Cost factor (2^cost rounds)

Cost 10 or higher

Legacy systems where neither above is available

72-byte password limit

PBKDF2-HMAC-SHA256

Iteration count

600,000 iterations

FIPS-140 compliance

CPU cost only, so GPUs attack it efficiently

Argon2 won the 2015 Password Hashing Competition, and its Argon2id variant is standardized in RFC 9106. PBKDF2 is the one to pick when you need FIPS-140 validated implementations, and it is permitted by NIST SP 800-63B-4.

flowchart TD
    q1{"Do you need FIPS-140 compliance?"}
    q2{"Is an Argon2id library available?"}
    q3{"Is scrypt available?"}
    pbkdf2["PBKDF2-HMAC-SHA256"]
    argon["Argon2id"]
    scrypt["scrypt"]
    bcrypt["bcrypt"]

    q1 -->|"Yes"| pbkdf2
    q1 -->|"No"| q2
    q2 -->|"Yes"| argon
    q2 -->|"No"| q3
    q3 -->|"Yes"| scrypt
    q3 -->|"No"| bcrypt

    style argon fill:#e6f0fa,stroke:#2b7de0,stroke-width:2px,color:#1a1a1a

Choosing the cost settings

OWASP’s numbers are minimums, not targets. In my test, Argon2id at its minimum took only about 23 ms, so there is room to spend more. The argon2-cffi library defaults to 64 MiB, 3 iterations and 4 lanes (the RFC 9106 low-memory profile), which is a stronger starting point.

You can also go too far. If one login costs your server a lot of CPU or memory, an attacker can slow it down just by sending many login attempts. With a memory-hard hash, 100 simultaneous logins at 19 MiB each need about 1.9 GB.

OWASP’s rule of thumb is that a hash should take less than one second. Run the script at the end of this post on your production hardware, raise the settings until you reach a latency you can afford, and revisit them as hardware improves.

Putting it into practice

Here is the whole life of a password. The stored record includes the algorithm and its settings, so you can change them later without breaking old accounts:

sequenceDiagram
    autonumber
    participant U as User
    participant S as Server
    participant DB as Database
    U->>S: Sign up with a password
    S->>S: Generate a random salt and hash the password
    S->>DB: Store algorithm, settings, salt and hash
    U->>S: Log in with a password
    S->>DB: Load the stored record
    DB-->>S: Algorithm, settings, salt and hash
    S->>S: Hash the attempt with the same salt and settings
    S->>S: Compare the two hashes in constant time
    S-->>U: Accept or reject the login

With the argon2-cffi package, the library does all of this for you:

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()                       # Argon2id with RFC 9106 low-memory defaults

stored = ph.hash(password)                  # random salt, settings included in the string
try:
    ph.verify(stored, attempt)              # raises if the password is wrong
except VerifyMismatchError:
    ...                                     # reject the login
else:
    if ph.check_needs_rehash(stored):       # are the stored settings out of date?
        stored = ph.hash(attempt)           # re-hash with current settings, then save it

No extra packages? Python’s standard library includes scrypt. This version shows what a library does under the hood: a fresh salt, settings stored with the hash, a constant-time comparison and a rehash check.

import base64, hashlib, hmac, os

LN, R, P = 17, 8, 1   # scrypt N=2^17 (128 MiB), r=8, p=1: OWASP's minimum for scrypt

def _scrypt(password, salt, ln, r, p):
    # maxmem must exceed about 128 * N * r bytes, so raise it if you raise ln
    return hashlib.scrypt(password.encode(), salt=salt, n=2**ln, r=r, p=p,
                          maxmem=2**28, dklen=32)

def hash_password(password):
    salt = os.urandom(16)                        # a fresh random salt for every password
    digest = _scrypt(password, salt, LN, R, P)
    b64 = lambda raw: base64.b64encode(raw).decode()
    return f'$scrypt$ln={LN},r={R},p={P}${b64(salt)}${b64(digest)}'

def verify_password(password, stored):
    _, _, params, salt, digest = stored.split('$')
    ln, r, p = (int(item.split('=')[1]) for item in params.split(','))
    candidate = _scrypt(password, base64.b64decode(salt), ln, r, p)
    return hmac.compare_digest(candidate, base64.b64decode(digest))   # constant-time compare

def needs_rehash(stored):
    return stored.split('$')[2] != f'ln={LN},r={R},p={P}'

The stored string is modeled on the PHC string format, a common standard for this.

Upgrading hashes you already have

Settings that are safe today won’t be forever, and you may have old hashes to replace. The most common approach, per OWASP, is to wait until the user next logs in, then re-hash their password:

flowchart TD
    login["User logs in"] --> verify["Verify the password against the stored hash"]
    verify --> ok{"Correct?"}
    ok -->|"No"| reject["Reject the login"]
    ok -->|"Yes"| old{"Old algorithm or weaker settings?"}
    old -->|"No"| done["Log the user in"]
    old -->|"Yes"| rehash["Re-hash the password with current settings and save it"]
    rehash --> done

    style rehash fill:#e6f0fa,stroke:#2b7de0,stroke-width:2px,color:#1a1a1a

Accounts that never log in keep their old hashes. OWASP suggests two ways to deal with them:

  • Expire them. Delete the old hashes of long-inactive users and make them reset their password. It’s secure, but it can confuse users, who may read it as a sign of a breach.

  • Wrap them. Feed the old hash into the new algorithm, for example bcrypt(md5(password)), and replace it with a direct hash at the next login. This protects dormant accounts at once, though layered hashes can be easier to crack.

Mistakes to avoid

  • Using a fast hash, even with a salt. MD5, SHA-1 and SHA-256 are all too fast for passwords.

  • Comparing hashes with ==. A normal comparison can leak timing information. Use a constant-time comparison, as in the code above.

  • Pre-hashing for bcrypt with a plain fast hash. bcrypt ignores anything past 72 bytes, and OWASP warns that wrapping it around a plain SHA-512 is dangerous. If you must pre-hash, it suggests an HMAC keyed with a pepper.

  • Restricting what users can type. Accept all Unicode characters and long passphrases, and don’t reduce the entropy of the input before hashing.

  • Setting the cost once and forgetting it. Revisit it regularly, and use the rehash-on-login pattern above to apply the change.

  • Treating the hash as the only defense. It protects a leaked database, not a live login form. Rate-limit login attempts and offer multi-factor authentication too.

Measure it yourself

This script produced the first table. Run it on your own hardware when you tune settings. It times each algorithm for about a second and prints guesses per second, time per hash and how long one core would need for a million guesses.

The Argon2id line needs a recent version of the cryptography package (I used version 46) and is skipped if it can’t be imported.

import hashlib, os, time

password = b'correct horse battery staple'
salt = os.urandom(16)

def per_second(fn, seconds=1.0, min_runs=3):
    count, start = 0, time.perf_counter()
    while count < min_runs or time.perf_counter() - start < seconds:
        fn()
        count += 1
    return count / (time.perf_counter() - start)

def human(secs):
    for limit, unit in ((60, 's'), (3600, 'min'), (86400, 'hours'), (365 * 86400, 'days')):
        if secs < limit:
            div = {'s': 1, 'min': 60, 'hours': 3600, 'days': 86400}[unit]
            return f'{secs / div:,.1f} {unit}'
    return f'{secs / (365 * 86400):,.1f} years'

tests = {
    'SHA-256': lambda: hashlib.sha256(salt + password).digest(),
    'PBKDF2-HMAC-SHA256, 600,000 iterations':
        lambda: hashlib.pbkdf2_hmac('sha256', password, salt, 600_000),
    'scrypt, N=2^17, r=8, p=1':
        lambda: hashlib.scrypt(password, salt=salt, n=2**17, r=8, p=1, maxmem=2**28),
}
try:
    from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
    tests['Argon2id, 19 MiB, t=2, p=1'] = lambda: Argon2id(
        salt=salt, length=32, iterations=2, lanes=1, memory_cost=19456).derive(password)
except ImportError:
    pass

print(f'{"algorithm":42} {"guesses/s":>14} {"per hash":>12} {"1M guesses":>14}')
for name, fn in tests.items():
    rate = per_second(fn)
    print(f'{name:42} {rate:>14,.1f} {1000 / rate:>9,.2f} ms {human(1_000_000 / rate):>14}')

Wrapping up

A password hash has one job: make every guess expensive, so a leaked database stays hard to crack. SHA-256 fails at that because it was built to be fast. Argon2id, scrypt, bcrypt and PBKDF2 succeed because they are slow on purpose, and the memory-hard ones also hold back GPUs.

Use Argon2id if you can, measure the cost on your own hardware, and plan to raise it over time.

Comments(0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

Arpan Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Arpan Singh's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.