--- title: "Password Hashing Explained: Why SHA-256 Is the Wrong Tool and What to Use Instead" author: "Arpan Singh" published: "2026-11-07T12:04:00.014Z" updated: "2026-11-07T16:17:40.590Z" url: "https://zyvop.com/password-hashing-explained-why-sha-256-is-the-wrong-tool-and-what-to-use-instead-v36wj" canonical: "https://zyvop.com/password-hashing-explained-why-sha-256-is-the-wrong-tool-and-what-to-use-instead-v36wj" tags: ["backend-development","password-security","authentication","cybersecurity","cryptography"] --- # 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. ![Cover image]() 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. ```mermaid 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. ```mermaid 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: ```mermaid 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: ```python 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. ```python 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: ```mermaid 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. ```python 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.