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.
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
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
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
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
Password hashes are slow on purpose, and salted
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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).
- 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.
- Stored, without the password. The table keeps A's salt and hash. The password itself is stored nowhere.
- 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.
- 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.
- One letter different. Monsoon-2024, with a capital M, gives a completely different hash, so compare_digest() returns False and the login is refused.
- 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:
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
An HMAC signs a link so that nobody can change it
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:
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
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
Step through the four checks:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- Another file. Someone changes marks-8b to marks-9a and keeps the signature. The recomputed HMAC differs, so the server refuses the link.
- 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.
- 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:
| Criterion | SHA-256 | HMAC-SHA256 | scrypt | Encryption |
|---|---|---|---|---|
| What it gives you | a fingerprint of data | a fingerprint only key holders can make | a slow, salted fingerprint of a password | data that only key holders can read back |
| Needs a secret key | no | yes | no, a salt per password | yes |
| Can be reversed | no | no | no | yes, with the key |
| In Python | hashlib.sha256 | hmac.new(key, msg, 'sha256') | hashlib.scrypt | the cryptography package |
| When to choose | Checksums of files | Signed links and webhooks | Stored passwords | Data that must stay private |
Checkpoint
Check: Signing a link with HMAC
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.
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.
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).
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
-
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.
-
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.
-
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.
-
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.
Key takeaways
- Use
secretsfor every token, code and key;randomis 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
hmacand compare signatures withhmac.compare_digest(). - Hashes and HMAC check data; only encryption hides it.
References
- secrets: generate secure random numbers for managing secrets (Python Software Foundation)
- random: generate pseudo-random numbers (Python Software Foundation)
- hashlib: key derivation (Python Software Foundation)
- hmac: keyed-hashing for message authentication (Python Software Foundation)
- uuid: UUID objects according to RFC 9562 (Python Software Foundation)
- RFC 2104: HMAC, keyed-hashing for message authentication (IETF)
- RFC 7914: the scrypt password-based key derivation function (IETF)
- RFC 9562: universally unique identifiers (UUIDs) (IETF)
- OWASP Password Storage Cheat Sheet (OWASP Foundation)
- cryptography: documentation (The Python Cryptographic Authority)
- argon2-cffi: documentation (The argon2-cffi project)
- Razorpay: validate and test webhooks (Razorpay)
- Pyodide: modules with limited functionality (The Pyodide project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress