Cryptography
Encryption vs Encoding vs Hashing: What Each One Does
Separate encoding, encryption and hashing with Base64, SHA-256 and OpenSSL labs. Learn why Base64 is not protection and hashes are one-way.
Why fast hashes fail for passwords, what salts and work factors do, and how to hash and verify with Argon2id in Python using argon2-cffi.
Password databases leak more often than teams plan for. When one does, the attacker gets hashes, not passwords. How much work it takes to get from one to the other depends on two choices: the function you used and the settings you gave it. This article explains why password storage needs a different kind of hash, what a salt and a work factor do, and why Argon2id is the choice current guidance points to. You will hash and verify a password in Python, read each part of the encoded hash, and rehash stored passwords when your settings change.
You need a terminal and Python 3. The lab installs one package, argon2-cffi.
A hash function turns input into a fixed-size output. SHA-256 does that very quickly, which is what you want for checksums and file integrity. For passwords, speed is the problem. A stolen password table gets attacked by guessing: take a candidate, hash it, compare, repeat. If each guess is cheap, the attacker can run billions of them on graphics cards.
The OWASP Password Storage Cheat Sheet is direct about this. SHA-256 is fast, and fast is exactly what an attacker needs when the input is a password, which is a short, human-chosen string.
You can measure the gap on your own machine. The benchmark in the lab hashes a password with salted SHA-256 and with Argon2id, then prints the rate of each.
A salt is a random value, different for every password, stored next to the hash. It does two jobs.
A salt is not a secret, and it does not make a weak password strong. Its job is to stop the attacker from reusing work across users. RFC 9106 recommends a 128-bit salt and allows 64 bits when space is tight. NIST SP 800-63B requires at least 32 bits. The argon2-cffi default is 16 bytes, which is 128 bits.
A work factor is a setting that makes each guess expensive on purpose. Older schemes such as PBKDF2 mostly count iterations. Argon2 has three parameters.
m): the RAM, in KiB, that each hash uses.t): how many passes the function makes over that memory.p): how many lanes can run at the same time.Memory matters most. A GPU has thousands of small cores, and each core has little memory of its own. If each guess needs tens of megabytes, the attacker cannot run as many guesses in parallel as they could against a function that needs a few kilobytes. This property is called memory-hardness.
The work factor is a dial, not a constant. Raise it as hardware gets faster and as your login budget allows. Store the settings with each hash, so older hashes can be recognized and upgraded later. The last steps of the lab do this.
Argon2 won the Password Hashing Competition in 2015. It has three variants.
Argon2id covers both concerns. RFC 9106 recommends it as the variant to use in general, and OWASP lists it first among its password hashing options.
RFC 9106 gives two recommended settings. The first, for systems with memory to spare, is 2 GiB, 1 pass, and 4 lanes. The second, for memory-constrained environments, is 64 MiB, 3 passes, and 4 lanes. The OWASP minimum is 19 MiB, 2 iterations, and 1 lane. OWASP also lists alternatives that trade memory for passes, such as 12 MiB with 3 passes or 7 MiB with 5 passes, all at 1 lane. The argon2-cffi defaults match the RFC's second set.
Treat these as floors and starting points, not targets. A common rule of thumb is to tune the settings until one hash takes a few hundred milliseconds on your production hardware. Then check that memory per hash, multiplied by the number of logins that can arrive at the same moment, still fits your servers. That rule comes from practice, not from either standard.
NIST approaches it differently. SP 800-63B requires passwords to be salted and hashed with a suitable scheme, and says an approved scheme from SP 800-132, which covers PBKDF2, should be used. It does not name Argon2id. If a compliance program requires NIST-approved algorithms, confirm with your assessor before you choose Argon2id.
When a password hashing library stores a password, five things happen.
Step 3 is why you do not need a separate salt column. The encoded string has this format.
$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>argon2id is the variant.v=19 is the Argon2 version, 1.3, written in decimal. In hex it is 0x13.m, t, and p are memory in KiB, time cost, and parallelism.<salt> and <hash> are base64 without padding. The hash is the derived output, 32 bytes by default.The string from the lab is 97 characters long. Use a column that holds at least 255 characters, or a text type. Do not split it into columns unless you enjoy writing your own parser.
The lab uses a local virtual environment and needs no network service. The output below comes from one Windows 11 machine with Python 3.12.10, argon2-cffi 25.1.0, and argon2-cffi-bindings 26.1.0. On that machine python3 was not on the PATH in Git Bash, so the commands ran as python. Use python3 if it works on yours. Your hash strings will differ from the ones shown, because the salt is random.
python3 -m venv .venv && . .venv/bin/activate
pip install argon2-cffiOn Windows, activate with .venv\Scripts\activate in PowerShell or cmd, or with . .venv/Scripts/activate in Git Bash.
python3 -c "from argon2 import PasswordHasher; ph=PasswordHasher(); h=ph.hash('correct horse battery'); print(h); print(ph.verify(h,'correct horse battery')); print(ph.check_needs_rehash(h))"Observed output:
$argon2id$v=19$m=65536,t=3,p=4$abVC6VF2EGJW1SCX/D6WBQ$XHKggONKypxBhdq7sWTbL/sMkglTAKu9y1FlL5p4dic
True
Falseverify returns True for the right password. check_needs_rehash returns False because this hash was made with the current defaults. Those defaults are a time cost of 3, a memory cost of 65536 KiB (64 MiB), parallelism of 4, a 32-byte hash, a 16-byte salt, and the ID variant.
python3 -c "from argon2 import PasswordHasher; from argon2.exceptions import VerifyMismatchError; ph=PasswordHasher(); h=ph.hash('a');
try: ph.verify(h,'b')
except VerifyMismatchError: print('mismatch detected')"Observed output:
mismatch detectedA wrong password does not return False. verify raises VerifyMismatchError, so the check belongs in an except block. A malformed stored string raises a different exception, InvalidHashError. Handle it as a server-side data problem, and do not let it reach the user's screen.
Hash the same password twice.
python3 -c "from argon2 import PasswordHasher; ph=PasswordHasher(); h=ph.hash('correct horse battery'); print(h)"
python3 -c "from argon2 import PasswordHasher; ph=PasswordHasher(); h=ph.hash('correct horse battery'); print(h)"Observed output:
$argon2id$v=19$m=65536,t=3,p=4$p8OU0plYOCO2/v70mjYTFA$FwhMW3ECJ0F/8rulKrJ158HjyNekURguaCIfoVk0i/o
$argon2id$v=19$m=65536,t=3,p=4$MTgAgrtBHJmc1eL2iKy8nA$h/7+8J6Cdb82XTBGWsLjbYBIh05934Yel4y6tyZ6NYgOne password, two different strings. The parameters match because both runs used the same defaults. Only the salt and the output differ. Someone reading the table cannot tell that these two accounts use the same password.
Save this as parts.py.
from argon2 import PasswordHasher
h = PasswordHasher().hash("correct horse battery")
print(h.split("$"))Run it with python3 parts.py. Observed output:
['', 'argon2id', 'v=19', 'm=65536,t=3,p=4', 'waS+Ec4iQCNIuhRQbv2sWQ', 'yLioGsHJoVv+vTu2kRoYmXTLjbbjJSe2tPSExz3Sd5A']The empty first element comes from the leading $. After it come the variant, the version, the parameters, the salt, and the hash. Verification needs nothing else, which is why one column is enough.
Settings change over time. This script creates a hash with weaker, older settings and then checks it against the current defaults. Save it as rehash.py.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
old = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
stored = old.hash('correct horse battery')
print('stored:', stored)
new = PasswordHasher() # current defaults
print('needs rehash under new params:', new.check_needs_rehash(stored))
try:
new.verify(stored, 'correct horse battery')
print('verify ok')
if new.check_needs_rehash(stored):
stored = new.hash('correct horse battery')
print('rehashed:', stored)
except VerifyMismatchError:
print('wrong password')
print('after rehash needs rehash:', new.check_needs_rehash(stored))Run python3 rehash.py. Observed output:
stored: $argon2id$v=19$m=19456,t=2,p=1$EWji+p6+3HbAKY+wkykcXg$NxPq/zGPsx9okK4vQCKfZ4BVoSOqeF1QkHxd3f2g2uE
needs rehash under new params: True
verify ok
rehashed: $argon2id$v=19$m=65536,t=3,p=4$FkifxeWUHV9eU7d8JFt6mg$ZFoMkNjYqo4D/bax/jPz80Hvr4mjLWRi4TVoqCoR6ng
after rehash needs rehash: FalseThe order matters. Verify first, then rehash, so only a correct password reaches the upgrade. In a real application, write the new string back to the same row right after a successful login. If you skip that step, old settings stay in the table indefinitely.
Save this as bench.py.
import hashlib, time, os
from argon2 import PasswordHasher
pw = b'correct horse battery'
salt = os.urandom(16)
n = 1_000_000
t = time.perf_counter()
for _ in range(n):
hashlib.sha256(salt + pw).digest()
dt = time.perf_counter() - t
print(f"sha256 salted: {n} hashes in {dt:.2f} s, {n/dt:,.0f} per second")
ph = PasswordHasher()
m = 10
t = time.perf_counter()
for _ in range(m):
ph.hash('correct horse battery')
dt = time.perf_counter() - t
print(f"argon2id default: {m} hashes in {dt:.2f} s, {dt/m*1000:.0f} ms per hash")Run python3 bench.py. Observed output:
sha256 salted: 1000000 hashes in 4.06 s, 246,343 per second
argon2id default: 10 hashes in 6.40 s, 640 ms per hashThese are single runs on one Windows machine. The SHA-256 line measures a Python loop, not optimized code, so it understates what dedicated hardware does with SHA-256. The Argon2id line is the cost each login pays. The exact figures matter less than the gap. A salted SHA-256 call takes about 4 microseconds here. An Argon2id call takes about 640 milliseconds, and that is the design. Tune m, t, and p on hardware that resembles production, until one login costs a few hundred milliseconds.
verify. Do not compare strings with ==.PasswordHasher, verify, and check_needs_rehash.Cryptography
Separate encoding, encryption and hashing with Base64, SHA-256 and OpenSSL labs. Learn why Base64 is not protection and hashes are one-way.
Cryptography
Public and private keys from zero: what a signature proves, why the private key stays secret, and an Ed25519 lab where a changed file fails.
Cryptography
Cryptography from zero: encryption, keys, hashes, signatures, and why a Caesar cipher teaches ideas but protects nothing.