Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 35 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free intro, Pro: 5 of 35 minutes free
Free: Passwords are guessed and secret tokens are not, so they are stored differently. With a pass: the other 8 sections and 3 interview answers, about 30 minutes.

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, and what twelve guesses cost against each kind Python · guess/guess_cost.py
# 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

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

  1. Question 1 of 2 In guess_cost.py the plain SHA-256 table cost the thief only 12 hash calls to try twelve guesses on five passwords. Why so few?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Without a salt, each guess is hashed once and compared with every record in the table

    A salt makes each record need its own pass over the list. That is why the salted table cost 120,000 calls, 10,000 times as many.

  2. Question 2 of 2 Why do the five tokens stay shut under plain SHA-256 while the five passwords do not?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: There is no list of likely tokens to try, because each one is 256 random bits from the server

    The difference lies in who chooses the value. A fast hash is enough for a token, and a slow salted one is needed for a password.

Flashcards · 4 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.