Web Security (OWASP Top 10:2025) Module 5 – A04:2025 Cryptographic Failures
Passwords and secret tokens for web apps
Store passwords with a slow, salted hash that upgrades at login, issue and check secret tokens, and stop a login form from giving accounts away.
What you will learn
- Choose a password hashing algorithm and its settings from OWASP's minimums, and store self-describing records
- Upgrade stored password hashes at login when the settings change
- Generate, hash and redeem reset, API and remember-me tokens
- Apply the NIST SP 800-63B-4 password policy to length, blocklists, composition and rotation
- Avoid user enumeration and timing leaks in the login path
Before you start
What you’ll learn · Before you start
What you will learn
- Choose a password hashing algorithm and its settings from OWASP's minimums, and store self-describing records
- Upgrade stored password hashes at login when the settings change
- Generate, hash and redeem reset, API and remember-me tokens
- Apply the NIST SP 800-63B-4 password policy to length, blocklists, composition and rotation
- Avoid user enumeration and timing leaks in the login path
Before you start
On this page
A table of password records and a table of reset tokens look alike, strings that open an account, and they need opposite handling. Passwords are chosen by people, so a thief with the table guesses them, and storage has to make every guess expensive. Secret tokens for web apps are chosen by the server, so nothing can be guessed, and storage has to make them hard to reuse and quick to check.
This lesson covers passwords and secret tokens for web apps: which password hash to use and how to set it, how to upgrade it at login, how to issue, store and check reset, API and remember-me tokens, which policy to set, and how a login form gives accounts away through its messages and its timing.
Passwords are guessed and secret tokens are not, so they are stored differently
People choose passwords from a short list of likely words and patterns, and they reuse them. Once a thief holds your table, the only defence left is the cost of each guess, and a slow hash with its own salt: A random value stored with each password hash and mixed in when it is made. It does not have to be secret. It gives equal passwords different records and forces a thief to attack each record on its own. for every record sets that cost. A server makes a token from a CSPRNG, with at least 128 bits, so there is no list of likely tokens to try. For a token a fast hash is enough, because the thief has nothing to guess.
The program below shows both effects on invented data. Ten stolen records are stored in three ways: five passwords that people chose from a list of twelve likely passwords, kept as a plain SHA-256 and then as a salted PBKDF2, and five random tokens kept as a plain SHA-256. The thief tries the twelve likely passwords on every record:
# Ten stolen records, stored in three ways. Five accounts have a password that a person chose from a list of likely
# passwords, and five have a token that the server made. A thief tries the same twelve likely passwords on every record.
# Every value is invented for this lab, and the PBKDF2 rounds are lab-sized: real settings are far higher.
import hashlib
import hmac
import secrets
LIKELY = [
"teapot-9", "blue-moon-1", "sun-flower-3", "river-stone", "paper-kite-7", "lemon-tree-2",
"north-star-5", "green-door-8", "silver-fish", "summer-rain-4", "quiet-owl-6", "brick-road-1",
]
CHOSEN = [2, 7, 2, 9, 4] # which likely password each of the five accounts chose; accounts 0 and 2 chose the same one
ROUNDS = 2_000
def sha256(text):
return hashlib.sha256(text.encode()).digest()
def kdf(password, salt, rounds):
"""PBKDF2-HMAC-SHA-256 with a 32-byte result. hashlib.pbkdf2_hmac does it where the runtime has it; the same steps are
written out for a browser runtime, which does not."""
if hasattr(hashlib, "pbkdf2_hmac"):
return hashlib.pbkdf2_hmac("sha256", password, salt, rounds, 32)
base = hmac.new(password, digestmod="sha256")
first = base.copy()
first.update(salt)
first.update(b"\x00\x00\x00\x01")
block = first.digest()
total = int.from_bytes(block, "big")
for _ in range(1, rounds):
step = base.copy()
step.update(block)
block = step.digest()
total ^= int.from_bytes(block, "big")
return total.to_bytes(32, "big")
def pbkdf2(text, salt):
return kdf(text.encode(), salt, ROUNDS)
def salted(text):
"""A record with its own random salt, as a password store keeps it."""
salt = secrets.token_bytes(16)
return salt, pbkdf2(text, salt)
passwords = [LIKELY[i] for i in CHOSEN]
tokens = [secrets.token_urlsafe(32) for _ in range(5)]
plain_passwords = [sha256(p) for p in passwords]
salted_passwords = [salted(p) for p in passwords]
hashed_tokens = [sha256(t) for t in tokens]
# The thief hashes each guess once and looks it up in the whole table, because nothing is salted.
guess_hashes = {sha256(g): g for g in LIKELY}
opened_plain = sum(h in guess_hashes for h in plain_passwords)
calls_plain = len(LIKELY)
opened_tokens = sum(h in guess_hashes for h in hashed_tokens)
# With a salt per record, every guess must be hashed again for every record.
opened_salted = sum(any(pbkdf2(g, salt) == h for g in LIKELY) for salt, h in salted_passwords)
calls_salted = len(salted_passwords) * len(LIKELY) * ROUNDS
print(f"{'stored as':<42}{'different values':>17}{'hash calls':>12} opened")
print(f"{'password, plain SHA-256':<42}{len(set(plain_passwords)):>17}{calls_plain:>12,} {opened_plain} of 5")
print(f"{'password, salted PBKDF2, 2,000 rounds':<42}{len({h for _, h in salted_passwords}):>17}{calls_salted:>12,} {opened_salted} of 5")
print(f"{'token, plain SHA-256':<42}{len(set(hashed_tokens)):>17}{calls_plain:>12,} {opened_tokens} of 5")
BAR = 100_000
print(f"\nbar for a password table: at least {BAR:,} hash calls for the list")
print(f" plain SHA-256 meets it: {calls_plain >= BAR}; salted PBKDF2 meets it: {calls_salted >= BAR}")
print(f"bar for a token table: no token opened: {opened_tokens == 0}") Output
stored as different values hash calls opened password, plain SHA-256 4 12 5 of 5 password, salted PBKDF2, 2,000 rounds 5 120,000 5 of 5 token, plain SHA-256 5 12 0 of 5 bar for a password table: at least 100,000 hash calls for the list plain SHA-256 meets it: False; salted PBKDF2 meets it: True bar for a token table: no token opened: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 guess_cost.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The same twelve guesses open all five passwords whichever way they are stored, because those passwords came from the list. What changes is the cost. Without a salt, one pass over the list serves the whole table: twelve hash calls. With a salt for each record and 2,000 rounds, the same list costs 120,000 calls, ten thousand times as many. The salt also hides who shares a password: the plain table holds four different values for five accounts, and the salted table holds five. The five tokens stay shut even under a plain SHA-256, because no list of likely tokens exists. The rounds here are lab-sized, and real settings are far higher.
Checkpoint
Check: Why passwords and tokens are stored differently
Flashcards · 4 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why are passwords and secret tokens stored differently?
People choose passwords from a short list of likely words and reuse them, so once a thief holds the table only the cost of each guess helps: a slow hash with its own salt for every record. A server draws a token from a CSPRNG at 128 bits or more, so there is nothing to guess and a fast hash is enough.
-
What does a per-record salt do for stored passwords?
Without a salt, one pass over a list of likely passwords serves the whole table (12 hash calls in the lesson); with a salt per record and 2,000 rounds the same list cost 120,000 calls, ten thousand times as many. A salt also hides who shares a password: the plain table held four different values for five accounts, the salted table five.
-
How much randomness should a secret token, such as a reset, API or remember-me token, have?
At least 128 bits from a CSPRNG. Because no list of likely tokens exists, the lesson's five random tokens stayed shut even under a plain SHA-256.
-
What must storage of server-chosen secret tokens achieve, given that nothing can be guessed?
Make them hard to reuse and quick to check. Since the server picks them from a CSPRNG, the thief has nothing to guess, which is why storage can differ from the slow, salted hashing that passwords need.
Choosing a password hash and setting its cost Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Store a self-describing record, and upgrade it at login
- Secret tokens: random, stored as a hash, single use and short lived
- A login form gives accounts away through its words and its time
- A password policy that follows NIST SP 800-63B-4
- Practise: a small credential store
- Store passwords in a record that upgrades at login
- Issue and redeem single-use tokens
- Interview questions
- An old system stores unsalted MD5 hashes of 400,000 users' passwords. How do you move to a modern hash, and what do you do about the users who never sign in again?
- A reset flow answers "no account with that address" for an unknown email, otherwise mails a link with a six-digit code that works for 24 hours and any number of times, and signs the user in after the reset. List what is wrong and how you fix it.
- A login endpoint hashes with Argon2id at 64 MiB on four instances. Twenty thousand attempts a minute from many addresses brought it down last night. Redesign the login path so that it stays up, keeps real users waiting no longer than before, and does not show which accounts exist.
- Key takeaways
2 diagrams · 4 runnable examples (JavaScript, Python) · 2 exercises with sample tests · 3 interview answers · about 51 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- password blocklist
- A list of passwords that are refused when someone chooses one: common, expected and compromised values and words tied to the service. NIST asks that the whole password be compared with the list.
- password hash
- A hash function built to be slow on purpose, such as Argon2id, scrypt, bcrypt or PBKDF2, with a cost setting. It makes every guess at a stolen password cost the thief time and, for some of them, memory.
- pepper
- A secret shared by all password records and kept apart from the database, in a vault or an HSM. It is optional defence in depth: it protects the records when the database is stolen without it.
- PHC string format
- A text format for a stored password hash: $id$, then an optional version and parameters, then the salt and the hash in Base64 without padding. The settings travel inside the string, so old and new records can live together.
- salt
- A random value stored with each password hash and mixed in when it is made. It does not have to be secret. It gives equal passwords different records and forces a thief to attack each record on its own.
- user enumeration
- Finding out which accounts exist from differences in a service's answers, such as the words, the status code or the response time. A login or reset form should give the same answer and do the same work for every name.
References
- Password Storage Cheat Sheet (OWASP Foundation)
- Authentication Cheat Sheet (OWASP Foundation)
- Forgot Password Cheat Sheet (OWASP Foundation)
- NIST SP 800-63B-4: Authentication and Authenticator Management (NIST)
- A04:2025 Cryptographic Failures (mapped CWEs, including CWE-916, CWE-759 and CWE-760) (OWASP Foundation)
- A07:2025 Authentication Failures (mapped CWEs, including CWE-521 and CWE-640) (OWASP Foundation)
- ASVS 5.0.0, V6 Authentication (6.1.1, 6.2.1 to 6.2.12 and 6.4.1) (OWASP Foundation)
- ASVS 5.0.0, V7 Session Management (7.2.3) (OWASP Foundation)
- ASVS 5.0.0, V11 Cryptography (11.4.2) (OWASP Foundation)
- ASVS 5.0.0, V16 Security Logging and Error Handling (16.2.5) (OWASP Foundation)
- RFC 9106: Argon2, the Memory-Hard Function for Password Hashing and Other Applications (IETF (RFC Editor))
- PHC string format (C2SP)
- crypto: Node.js v24 documentation (argon2Sync, scryptSync, pbkdf2Sync) (OpenJS Foundation (Node.js))
- hashlib: secure hashes and message digests (pbkdf2_hmac) (Python Software Foundation)
- Have I Been Pwned API v3, Pwned Passwords (Have I Been Pwned)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress