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.

Python  Module 16 – Practical Python: text, CLIs, web, automation and security

Secure tokens, password hashes and HMAC

Make secure tokens with secrets, store password hashes with scrypt, sign links and webhooks with HMAC and compare them safely with the standard library.

  • Advanced
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: secrets makes tokens that nobody can predict · Password hashes are slow on purpose, and salted · An HMAC signs a link so that nobody can change it · Check yourself. With a pass: 2 in-depth parts and 3 interview answers, about 12 minutes.

What you will learn

  • Generate secure tokens with the secrets module, and size them for their job
  • Store password hashes with a salt and a slow key-derivation function
  • Protect links and messages with hmac signatures, and check them with compare_digest
  • Choose scrypt and PBKDF2 settings from measured costs, and upgrade them at login
  • Verify webhook signatures over the raw request body, and rotate signing keys

Before you start

What you’ll learn · Before you start

What you will learn

  • Generate secure tokens with the secrets module, and size them for their job
  • Store password hashes with a salt and a slow key-derivation function
  • Protect links and messages with hmac signatures, and check them with compare_digest
  • Choose scrypt and PBKDF2 settings from measured costs, and upgrade them at login
  • Verify webhook signatures over the raw request body, and rotate signing keys

Before you start

On this page

A reset link needs a token that nobody can guess among 2²⁵⁶ possible values, a copied user table must not reveal a single password, and a server must be able to tell a real payment notification from an invented one. Three modules of the standard library cover these jobs, and none of them encrypts anything.

The secrets module makes secure tokens: reset links, API keys and one-time codes that nobody can predict, which random, built for simulation, cannot promise. Password hashes are slow, salted fingerprints that let a server check a login without keeping the password. hashlib.scrypt makes them; a fast hash such as SHA-256 is the wrong tool there. An HMAC, from the hmac module, is a fingerprint that only holders of a secret key can make, so a server can check that a link or a webhook was not changed, comparing with hmac.compare_digest(). Hashes and HMAC check data and never hide it: for data that must stay private, use encryption from the cryptography package.

secrets makes tokens that nobody can predict

Token sizes from secrets, and why seeded random numbers are no secret Python · tokens.py
import math
import random
import secrets
import string

URL_SAFE = set(string.ascii_letters + string.digits + "-_")

print("token_bytes(32):  ", len(secrets.token_bytes(32)), "bytes, 256 bits")
print("token_hex(16):    ", len(secrets.token_hex(16)), "hex characters, 128 bits")
link_token = secrets.token_urlsafe(32)
print("token_urlsafe(32):", len(link_token), "characters, URL-safe:", set(link_token) <= URL_SAFE)
code = f"{secrets.randbelow(10**6):06d}"
print("one-time code:    ", len(code), "digits,", f"{math.log2(10**6):.1f} bits")
password = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
print("16 characters from 62:", len(password), "characters,", f"{16 * math.log2(62):.0f} bits")

# random is built for simulations: one seed gives the same numbers every time, on every computer.
random.seed(2024)
first = [random.randrange(10**6) for _ in range(3)]
random.seed(2024)
again = [random.randrange(10**6) for _ in range(3)]
print("random, seeded twice:", first, "==", again, first == again)

Output

token_bytes(32):   32 bytes, 256 bits
token_hex(16):     32 hex characters, 128 bits
token_urlsafe(32): 43 characters, URL-safe: True
one-time code:     6 digits, 19.9 bits
16 characters from 62: 16 characters, 95 bits
random, seeded twice: [492925, 190532, 763640] == [492925, 190532, 763640] True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tokens.py

token_urlsafe(32) draws 32 random bytes from the operating system and writes them as 43 characters that need no escaping in a URL: the right shape for a password-reset link or an invitation. The secrets documentation names 32 bytes as enough for the typical case, and every secure token in this lesson uses at least 16. randbelow() and choice() build codes and passwords from the same source. The last line shows why random is not a source of secure tokens: with the same seed: The starting value of a pseudo-random generator. The same seed gives the same stream of numbers, so random.seed(42) or random.Random(42) makes a simulation or a test repeatable. More in “Random numbers, simulations and statistics” it produced the same three numbers twice, and its documentation says plainly that it must not be used for security purposes.

A one-time code is short on purpose, so its strength comes from limits, not from its length. Six digits are a million values, about 20 bits. With five attempts allowed per code and a code that expires after ten minutes, a person guessing at random succeeds once in 200,000 codes, while an unlimited endpoint could be tried until the code is found. So store an expiry and an attempt count beside every one-time code, and store a hash of each reset token rather than the token itself, so that a copy of the database holds no link that still works.

Checkpoint

Check: Secure tokens from secrets

  1. Question 1 of 2 Which call should make the token in a password-reset link?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: secrets.token_urlsafe(32)

    secrets draws from the operating system's secure random source; token_urlsafe(32) gives 256 random bits in a form that needs no escaping in a URL.

  2. Question 2 of 2 A six-digit code allows 5 attempts before it is blocked. With one guess at random per attempt, about one code in how many is guessed?

    Type a number.

    A hint

    Six digits give 1,000,000 values. How many of them do 5 attempts cover?

    Show the answer: Question 2 of 2

    Answer: 200000 codes

    5 attempts cover 5 of the 1,000,000 values, so a random guesser succeeds once in 1,000,000 ÷ 5 = 200,000 codes. The limit and the expiry make a short code strong; without them, the code could be tried until it is found.

Password hashes are slow on purpose, and salted

Two accounts, one password: scrypt with a salt, and plain SHA-256 Python · passwords.py
import hashlib
import hmac
import secrets
import time

N, R, P = 2**14, 8, 1          # scrypt's cost settings: memory and time grow with n


def hash_password(password):
    """A fresh random salt and the scrypt hash of password with it: store both, never the password."""
    salt = secrets.token_bytes(16)
    return salt, hashlib.scrypt(password.encode(), salt=salt, n=N, r=R, p=P, dklen=32)


def verify_password(password, salt, stored):
    """Hash what was typed with the stored salt and compare in constant time."""
    candidate = hashlib.scrypt(password.encode(), salt=salt, n=N, r=R, p=P, dklen=32)
    return hmac.compare_digest(candidate, stored)


salt_a, hash_a = hash_password("monsoon-2024")
salt_b, hash_b = hash_password("monsoon-2024")
print("same password, two accounts: same hash?", hash_a == hash_b)
print("right password accepted:", verify_password("monsoon-2024", salt_a, hash_a))
print("one letter different accepted:", verify_password("Monsoon-2024", salt_a, hash_a))
print("plain SHA-256 of it, twice: same?", hashlib.sha256(b"monsoon-2024").digest() == hashlib.sha256(b"monsoon-2024").digest())

start = time.perf_counter()
for _ in range(10_000):
    hashlib.sha256(b"monsoon-2024").digest()
sha = (time.perf_counter() - start) / 10_000
start = time.perf_counter()
hash_password("monsoon-2024")
slow = time.perf_counter() - start
print(f"one SHA-256: {sha * 1e6:.2f} µs; one scrypt with n=2**14: {slow * 1e3:.0f} ms, about {slow / sha:,.0f} times as long")

Output

same password, two accounts: same hash? False
right password accepted: True
one letter different accepted: False
plain SHA-256 of it, twice: same? True
one SHA-256: 0.48 µs; one scrypt with n=2**14: 46 ms, about 95,403 times as long

This output changes from run to run: the two times are measured on each run and depend on the computer; the four answers above them are the same every time

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 passwords.py

Step through the same two accounts and two logins:

Interactive · message by message Two accounts, one password, two different hashes Step through passwords.py. Watch what the user table holds, and what a login has to do to check a password.
Two accounts, one password, two different hashes, step 7 of 7. Messages so far: sign-up and login to hashlib.scrypt(): password + salt A; hashlib.scrypt() to the user table: salt A, hash A; hashlib.scrypt() to the user table: salt B, hash B; the user table to sign-up and login: salt A for the check.sign-up and loginMonsoon-2024: refusedhashlib.scrypt()SHA-256: same, and fastthe user tableA and B: two different hashespassword + salt Asalt A, hash Asalt B, hash Bsalt A for the check

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Account A signs up. Account A chooses the password monsoon-2024. The program makes a fresh salt of 16 random bytes with secrets.token_bytes(16).
  2. A slow hash. scrypt turns the password and the salt into a 32-byte hash. It is slow on purpose, tens of milliseconds a call here.
  3. Stored, without the password. The table keeps A's salt and hash. The password itself is stored nowhere.
  4. Account B, same password. Account B signs up with the same password, monsoon-2024, and gets a salt of its own, so scrypt produces a different hash.
  5. A logs in. At login the program reads A's salt, runs scrypt again on what was typed, and compares the result with the stored hash using hmac.compare_digest(). They are equal, so the login is accepted.
  6. One letter different. Monsoon-2024, with a capital M, gives a completely different hash, so compare_digest() returns False and the login is refused.
  7. Plain SHA-256 instead. SHA-256 of the same password gives the same digest every time, so equal passwords would stand out in the table, and one SHA-256 takes well under a microsecond, so guesses would be cheap.

Each account got its own random salt, so the same password produced two different hashes, and nobody who reads the table can tell that the two users share a password. Plain SHA-256 gives the same digest every time, so equal passwords stand out and a precomputed list of common passwords matches them at once. Password hashes must also be slow to compute: one scrypt call took around 100,000 times as long as one SHA-256, and that cost is paid for every guess by anyone who tries guesses against a copied table, while a login pays it once. hashlib.pbkdf2_hmac() is the other slow function of the standard library: PBKDF2, an older standard, repeats a keyed hash many thousands of times. This example ran on CPython only: the browser runtime of these lessons is built without OpenSSL, so its hashlib has neither scrypt() nor pbkdf2_hmac(). Sign-up and login each run the same few steps:

Flow: at sign-up a salt and scrypt turn a password into a stored hash; at login scrypt runs again and compare_digest checks it.Sign-upStored for the accountsettings, salt, hashnever the passwordLog-inpassword typedrandom saltsecrets.token_bytes(16)scrypt(password, salt,n, r, p)password typed againscrypt with the storedsalt and settingshmac.compare_digest(new hash, stored hash)saveread

Password hashes are stored with their salt and settings, and checked by hashing again

Text description of the diagram

Sign-up: 1. The user types a password. 2. The program makes a new random salt with secrets.token_bytes(16). 3. scrypt, a slow key-derivation function, turns the password and the salt into a hash, using cost settings n, r and p. 4. The account stores the settings, the salt and the hash. The password itself is never stored. Log-in: 5. The user types the password again. 6. The program reads the stored salt and settings and runs scrypt again with them. 7. hmac.compare_digest() compares the new hash with the stored one in constant time. They are equal only when the password is the same.

Checkpoint

Check: Salted, slow password hashes

  1. Question 1 of 2 Why does each stored password hash get its own random salt?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: So that equal passwords give different hashes, and a precomputed list of hashes matches none of them

    Without a salt, everyone with the same password has the same hash, which reveals who shares a password and lets a table of hashes of common passwords match many accounts at once.

  2. Question 2 of 2 A table stores SHA-256 hashes of passwords, each with a salt. Why is that still the wrong tool?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: SHA-256 is fast, so anyone with a copy of the table can try a huge number of guesses for every account

    A salt stops shared passwords from standing out, and slowness stops cheap guessing. A stored password needs both, which is what scrypt, Argon2id or PBKDF2 give and SHA-256 does not.

An HMAC: A tag computed from data and a secret key with a hash function such as SHA-256, as hmac.digest() does. Without the key nobody can make a tag that matches changed data, so a matching tag shows the data is what a key holder signed. More in “pickle, shelve and safe serialisation” is a hash computed with a secret key: only someone with the key can produce it, and anyone with the key can check it. That makes it the tool for links and messages that a server issues and later accepts again:

A signed download link: accepted as sent, refused when changed or late Python · signed_link.py
import hashlib
import hmac
from urllib.parse import parse_qs, urlencode, urlsplit

# A real key is 32 random bytes from secrets.token_bytes(32), kept in the environment or a secret store.
KEY = hashlib.sha256(b"a fixed key for this example only").digest()


def sign_link(path, expires):
    """A link that is valid until `expires` (a Unix time) and cannot be changed without the key."""
    message = f"{path}\n{expires}".encode()
    signature = hmac.new(KEY, message, hashlib.sha256).hexdigest()
    return f"{path}?{urlencode({'expires': expires, 'sig': signature})}"


def check_link(link, now):
    parts = urlsplit(link)
    query = parse_qs(parts.query)
    expires, signature = query["expires"][0], query["sig"][0]
    expected = hmac.new(KEY, f"{parts.path}\n{expires}".encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        return "refused: the signature does not match"
    if now > int(expires):
        return "refused: the link has expired"
    return "accepted"


link = sign_link("/reports/marks-8b.pdf", expires=1_700_003_600)
print("signed:", link[:60] + "...")
for label, candidate, now in [
    ("as sent, an hour before expiry", link, 1_700_000_000),
    ("another file, same signature", link.replace("marks-8b", "marks-9a"), 1_700_000_000),
    ("a later expiry, same signature", link.replace("1700003600", "1800003600"), 1_700_000_000),
    ("as sent, a minute after expiry", link, 1_700_003_660),
]:
    print(f"{label:32} {check_link(candidate, now)}")

Output

signed: /reports/marks-8b.pdf?expires=1700003600&sig=a18cf10aac1c5e7...
as sent, an hour before expiry   accepted
another file, same signature     refused: the signature does not match
a later expiry, same signature   refused: the signature does not match
as sent, a minute after expiry   refused: the link has expired

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 signed_link.py

Step through the four checks:

Interactive · message by message A signed download link, checked four times Step through signed_link.py. The server never stores the link; it recomputes the signature each time and compares.
A signed download link, checked four times, step 6 of 6. Messages so far: the server to the link: HMAC(key, path + expires); the link to whoever has the link: the link as sent; whoever has the link to the server: link as sent; whoever has the link to the server: path changed to 9a; whoever has the link to the server: expiry changed; whoever has the link to the server: as sent, 1 minute late.the serverrefused: expiredthe linkmarks-8b.pdf, sig a18c…whoever has the linkmay change itHMAC(key, path + expires)the link as sentlink as sentpath changed to 9aexpiry changedas sent, 1 minute late

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Sign path and expiry. The server joins the path /reports/marks-8b.pdf and the expiry time with a newline and computes HMAC-SHA256 of them with its secret key.
  2. Sent as it is. The link travels in an email. Anyone can read it, and only the key holder can make a signature that matches.
  3. Opened in time. An hour before the expiry, the server recomputes the HMAC from the link's path and expiry and compares it with sig using compare_digest(). They match, so the download is accepted.
  4. Another file. Someone changes marks-8b to marks-9a and keeps the signature. The recomputed HMAC differs, so the server refuses the link.
  5. A later expiry. Changing the expiry to a later time breaks the signature in the same way, because the expiry is part of what was signed.
  6. Too late. Opened a minute after the expiry, the unchanged link has a valid signature, and the server still refuses it, because the time has passed.

The signature covers the path and the expiry time together, so changing either one gave a signature the server did not expect, and the link stopped working after its expiry although the signature was intact. hmac.compare_digest() compared the signatures; the hmac documentation explains that it avoids the content-based short cuts of ==, whose running time can depend on how much of a secret value matches. The key itself must be long and random, such as secrets.token_bytes(32), and live in the environment or a secret store, never in the code or the repository. The four tools of this lesson are easy to confuse, so here they are side by side:

Four tools that are easy to confuse
Criterion SHA-256HMAC-SHA256scryptEncryption
What it gives you a fingerprint of dataa fingerprint only key holders can makea slow, salted fingerprint of a passworddata that only key holders can read back
Needs a secret key noyesno, a salt per passwordyes
Can be reversed nononoyes, with the key
In Python hashlib.sha256hmac.new(key, msg, 'sha256')hashlib.scryptthe cryptography package
When to choose Checksums of filesSigned links and webhooksStored passwordsData that must stay private
HMAC Generator Compute an HMAC-SHA256 of a message with your own test key, to compare with what your Python code produces.

Checkpoint

Check: Signing a link with HMAC

  1. Question 1 of 2 Someone changes a signed link's expiry from 1700003600 to 1800003600 and keeps the signature. Why does check_link() refuse it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The signature was computed over the path and the old expiry, so the new expiry gives a different expected signature

    Everything a link promises, the file and the expiry, must be inside the signed message. Then any change to it gives a signature that the server does not expect.

  2. Question 2 of 2 A team wants to keep customers' phone numbers private in their database. Which tool does that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Encryption with a key, for example Fernet from the cryptography package

    Hashes, HMACs and password hashes are one-way fingerprints that check data. Data that must stay private and still be used needs encryption.

Check yourself

Check tokens, password hashes and signatures with the questions below, which begin with the topic you know least.

Practice that adapts to you

Practise this lesson

9 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 9 Why must the random module never make tokens, codes or keys?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: It is built for simulation and is predictable from its seed or its earlier output

    random is a pseudo-random generator for modelling and simulation. secrets draws from the operating system's secure source and is the module for anything that must not be guessed.

  2. Question 2 of 9 How many characters long is the text of secrets.token_urlsafe(32)?

    Type a number.

    Show the answer: Question 2 of 9

    Answer: 43 characters

    32 bytes are 256 bits, and the URL-safe base64 alphabet carries 6 bits per character: 256 ÷ 6 rounds up to 43 characters, as the run printed.

  3. Question 3 of 9 A database of reset tokens is copied by someone who should not have it. What design limits the damage?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: Storing a hash of each token, with an expiry, instead of the token itself

    Treat stored reset tokens like passwords: store a hash and an expiry, compare the hash of the token that comes back, and delete it once used.

  4. Question 4 of 9 In passwords.py, two accounts use the same password. What does same hash? print, and why?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: False, because each account got its own random salt

    The salt makes each hash unique even for equal passwords, and storing it beside the hash lets the login repeat exactly the same calculation.

  5. Question 5 of 9 How does verify_password() check a login?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: It runs scrypt again on what was typed with the stored salt and compares the result with compare_digest()

    A login repeats the sign-up calculation with the stored salt and compares the two hashes in constant time.

  6. Question 6 of 9 Why did passwords.py run on CPython only, not in the browser's Python?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: The browser runtime is built without OpenSSL, so its hashlib has neither scrypt() nor pbkdf2_hmac()

    Which hashlib functions exist depends on how Python was built. Check with hasattr(hashlib, "scrypt") before relying on it in an unusual runtime.

  7. Question 7 of 9 What does the signature of the download link cover?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: The path and the expiry time, joined by a newline

    Sign every field the link promises. The server recomputes the HMAC from what arrives, so a change to any signed field gives a signature it does not expect.

  8. Question 8 of 9 Why compare signatures with hmac.compare_digest() instead of ==?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: It avoids the content-based short cuts of ==, whose running time can depend on how much of the value matches

    An attacker who can measure response times could learn how much of a guessed signature was right. compare_digest() takes the same time however much matches.

  9. Question 9 of 9 A pull request adds KEY = b"shop-signing-key" to the source code to sign download links. What do you ask for?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: A long random key from secrets.token_bytes(32), read from the environment or a secret store, never the code

    A signing key must be long, random and kept out of the code, so that only the running server holds it.

Password hash settings, measured, and a format you can upgrade Pro: opens with a Pro, Premium or Ultimate pass

The settings of a password hash decide how much each guess costs and how many logins a server can handle. This part measures scrypt and PBKDF2 at the settings OWASP recommends, shows the memory limit that refuses them by default, and stores hashes in a format that lets you raise the settings later without a password reset.

In this part:

  • scrypt and PBKDF2 measured, and the limit that refuses the recommended settings
  • A stored format that carries its settings, upgraded at login

1 diagram · 2 runnable examples (Python) · about 6 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).

Signatures in production: webhooks, key rotation and their limits Pro: opens with a Pro, Premium or Ultimate pass

Signatures fail in production for dull reasons: a framework that re-encodes a body, a key that has to change, a check that compares the wrong thing. This part verifies a payment webhook over its raw bytes, rotates signing keys without breaking links already sent, sets out what HMAC and hashes cannot do, and ends with an exercise.

In this part:

  • Webhook signatures check bytes, not parsed JSON: the failure reproduced
  • Rotating keys without breaking links already sent
  • What HMAC, hashes and UUIDs do not do
  • Exercise: store passwords and sign download links
    • Store passwords and sign download links

2 runnable examples (Python) · 1 exercise with sample tests · about 8 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Interview questions

  1. Fresher to mid · Warm-up

    When do you use the random module, and when the secrets module?

    random is for modelling and simulation: shuffling a quiz, sampling test data, a game. It is a pseudo-random generator, so the same seed gives the same numbers on every computer, which makes simulations repeatable and also makes its output predictable, and its documentation says it must not be used for security. secrets draws from the operating system's secure random source and is the module for anything that must not be guessed: password-reset links, API keys, session tokens, one-time codes and generated passwords. token_urlsafe(32) gives 32 random bytes as 43 URL-safe characters, randbelow() and choice() build codes and passwords, and token_bytes(32) makes keys. When in doubt, ask whether someone would gain by predicting the value; if so, use secrets.

  2. Mid to senior

    A legacy app stores passwords as unsalted SHA-256 hex digests for 200,000 users. Plan the move to a proper password hash without forcing everyone to reset their password.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    Your payment webhook handler rejects every genuine notification with "signature mismatch", although the webhook secret is right. The web framework parses JSON before your code runs. Diagnose it and fix the handler.

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    Design one-time-code login by SMS for an app with 5 million users: how the code is made, stored and checked, and how you stop guessing.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Use secrets for every token, code and key; random is for simulations.
  • Store passwords as a salt and a slow hash, such as hashlib.scrypt, never as a fast hash.
  • Sign links and messages with hmac and compare signatures with hmac.compare_digest().
  • Hashes and HMAC check data; only encryption hides it.

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.