Skip to content
aviral gupta

// A5.5 · ~45 min · Advanced

Secrets, hashing and publishing

After this lesson you can make tokens with secrets, hash and sign data with hashlib and hmac, store passwords with a salted, slow hash, check a pyproject.toml before python3 -m build, and finish the module with a safe t-string report generator.

Lesson 5 of 5 in A5 Modern Python and safe code

End of the module

You will be able to

  • Make tokens with secrets and checksums with hashlib
  • Sign and verify data with hmac and compare_digest, and hash passwords with a salt
  • Build a safe t-string report generator and prepare a package for build and twine
  1. Warm-up · Activity 1 of 7

    Warm-up from the previous lesson: json can only build plain data. What does this print?

    import json
    
    print(json.loads('[1, 2]') == [1, 2], type(json.loads('true')).__name__)
  2. Predict · Activity 2 of 7

    Predict before you read on: one byte and 100,000 bytes go through SHA-256. What does this print?

    import hashlib
    
    short = hashlib.sha256(b"a").hexdigest()
    long = hashlib.sha256(b"a" * 100_000).hexdigest()
    print(len(short), len(long))
  3. Practice · Activity 3 of 7

    Fill in the secrets function that returns 16 random bytes as URL-safe text.

    import secrets
    import string
    
    token = secrets.____(16)
    allowed = set(string.ascii_letters + string.digits + "-_")
    print(len(token), set(token) <= allowed)
    token = secrets.(16)
  4. Practice · Activity 4 of 7

    A server signs "user=ada". What does this print?

    import hmac
    
    key = b"server-key"
    tag = hmac.new(key, b"user=ada", "sha256").hexdigest()
    again = hmac.new(key, b"user=ada", "sha256").hexdigest()
    forged = hmac.new(key, b"user=admin", "sha256").hexdigest()
    print(hmac.compare_digest(tag, again), hmac.compare_digest(tag, forged))
  5. Practice · Activity 5 of 7

    Match each job to the tool made for it.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. compare_digest is strict about its arguments. What does this print?

    import hmac
    
    for a, b in [("abc", "abc"), (b"abc", b"abc"), ("abc", b"abc")]:
        try:
            print(hmac.compare_digest(a, b), end=" ")
        except TypeError:
            print("TypeError", end=" ")
  7. Apply · Activity 7 of 7

    Mini-task. Write sign_cookie(value), which returns value + "|" + its HMAC-SHA256 hex tag under a server key, and read_cookie(cookie), which returns the value if the tag matches and None otherwise. Compare with hmac.compare_digest. Try a genuine cookie, one where ada was changed to admin, and one with a made-up tag.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A token, a checksum and a signature

The program makes a reset token with secrets and prints only its length, since the value changes every run. It hashes a small CSV report with SHA-256, then signs it with hmac and checks two copies: one unchanged, and one with an extra row added. Only the unchanged copy matches the tag.

main.py

import hashlib
import hmac
import secrets

# 1. A token for a reset link: secrets, never random.
token = secrets.token_urlsafe(32)
print("token length:", len(token))

# 2. A checksum: the same bytes always give the same SHA-256 digest.
data = b"report,2026\nada,3\n"
print("sha256:", hashlib.sha256(data).hexdigest()[:16])

# 3. A signature: only someone with the key can make a matching tag.
key = b"keep-this-secret"
tag = hmac.new(key, data, "sha256").hexdigest()
received = hmac.new(key, data, "sha256").hexdigest()
tampered = hmac.new(key, data + b"bob,9\n", "sha256").hexdigest()
print("genuine:", hmac.compare_digest(tag, received))
print("tampered:", hmac.compare_digest(tag, tampered))

Run it with

python main.py

Output

token length: 43
sha256: 24ba945e8f82a042
genuine: True
tampered: False
  • token_urlsafe(32) gives 43 characters: 32 bytes in URL-safe base64, without padding.
  • The SHA-256 line is the same on every run and every computer.
  • Without the key, nobody can make a tag that matches the tampered report.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 4

Build step 1: tokens and signatures

Fix three functions. make_token uses random and only 8 bytes; return secrets.token_urlsafe(32). sign hashes key + text, which is not an HMAC; return the hex HMAC-SHA256 tag of text.encode() under key. verify compares with ==; use hmac.compare_digest.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    import secrets and hmac; random and hashlib are no longer needed.

  2. Hint 2

    hmac.new(key, text.encode(), "sha256").hexdigest() is the tag.

  3. Hint 3

    verify returns hmac.compare_digest(sign(key, text), tag).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import hmac
import secrets


def make_token() -> str:
    """Return a URL-safe token with 32 bytes of randomness."""
    return secrets.token_urlsafe(32)


def sign(key: bytes, text: str) -> str:
    """Return the hex HMAC-SHA256 tag of text under key."""
    return hmac.new(key, text.encode(), "sha256").hexdigest()


def verify(key: bytes, text: str, tag: str) -> bool:
    """Return True if tag is the right tag for text under key."""
    return hmac.compare_digest(sign(key, text), tag)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import hashlib
import random


def make_token() -> str:
    """Return a URL-safe token with 32 bytes of randomness."""
    return "%016x" % random.getrandbits(64)


def sign(key: bytes, text: str) -> str:
    """Return the hex HMAC-SHA256 tag of text under key."""
    return hashlib.sha256(key + text.encode()).hexdigest()


def verify(key: bytes, text: str, tag: str) -> bool:
    """Return True if tag is the right tag for text under key."""
    return sign(key, text) == tag

test_main.py

import string

from main import make_token, sign, verify

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


def test_token_shape():
    """A token has 43 URL-safe characters (32 random bytes)"""
    token = make_token()
    assert len(token) == 43 and set(token) <= URLSAFE, f"make_token gave {token!r}; use secrets.token_urlsafe(32)"


def test_tokens_differ():
    """Two tokens are different"""
    assert make_token() != make_token(), "make_token returned the same token twice"


def test_sign_is_hmac():
    """sign returns the HMAC-SHA256 tag"""
    got = sign(b"key", "msg")
    want = "2d93cbc1be167bcb1637a4a23cbff01a7878f0c50ee833954ea5221bb1b8c628"
    assert got == want, f"sign(b'key', 'msg') gave {got!r}; use hmac.new(key, text.encode(), 'sha256')"


def test_verify():
    """verify accepts the right tag and refuses changed text or another key"""
    tag = sign(b"key", "user=ada")
    assert verify(b"key", "user=ada", tag), "the correct tag was refused"
    assert not verify(b"key", "user=admin", tag), "a tag for other text was accepted"
    assert not verify(b"other", "user=ada", tag), "a tag made with another key was accepted"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 4

Build step 2: storing passwords

hash_password stores a bare SHA-256 of the password: no salt, and fast to guess. Store "salt$hash" instead: 16 random bytes from secrets as the salt, and hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITERATIONS) as the hash, both in hex. check_password recomputes with the stored salt and compares with hmac.compare_digest. Run the tests on your own computer: the browser has no pbkdf2_hmac.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    salt = secrets.token_bytes(16), then return salt.hex() + "$" + digest.hex().

  2. Hint 2

    In check_password, split on "$" and turn the salt back into bytes with bytes.fromhex.

  3. Hint 3

    Compare digest.hex() with the stored hex using hmac.compare_digest.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import hashlib
import hmac
import secrets

ITERATIONS = 100_000


def hash_password(password: str) -> str:
    """Return "salt$hash" in hex, with a new random salt each time."""
    salt = secrets.token_bytes(16)
    digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITERATIONS)
    return salt.hex() + "$" + digest.hex()


def check_password(password: str, stored: str) -> bool:
    """Return True if password matches what hash_password stored."""
    salt_hex, digest_hex = stored.split("$")
    digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt_hex), ITERATIONS)
    return hmac.compare_digest(digest.hex(), digest_hex)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import hashlib

ITERATIONS = 100_000


def hash_password(password: str) -> str:
    """Return "salt$hash" in hex, with a new random salt each time."""
    # sha256 of the bare password: no salt, and fast to brute-force.
    return "$" + hashlib.sha256(password.encode()).hexdigest()


def check_password(password: str, stored: str) -> bool:
    """Return True if password matches what hash_password stored."""
    return hash_password(password) == stored

test_main.py

import hashlib

from main import check_password, hash_password


def test_round_trip():
    """The right password matches, a wrong one does not"""
    stored = hash_password("hunter2")
    assert check_password("hunter2", stored), "the right password was refused"
    assert not check_password("hunter3", stored), "a wrong password was accepted"


def test_salted():
    """The same password hashed twice gives different results"""
    assert hash_password("hunter2") != hash_password("hunter2"), "no random salt: equal passwords give equal hashes"


def test_uses_pbkdf2():
    """The stored hash is PBKDF2-HMAC-SHA256 with a 16-byte salt"""
    salt_hex, _, digest_hex = hash_password("pw").partition("$")
    assert len(salt_hex) == 32, f"the salt part is {salt_hex!r}; use 16 random bytes, in hex"
    want = hashlib.pbkdf2_hmac("sha256", b"pw", bytes.fromhex(salt_hex), 100_000).hex()
    assert digest_hex == want, "the hash part is not pbkdf2_hmac('sha256', password, salt, ITERATIONS)"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 3 of 4

Build step 3: a safe report with t-strings

render(template) inserts values raw, and report builds its lines with f-strings, so a name such as "<b>bob</b>" becomes HTML. Make render apply each interpolation's conversion and format spec, then html.escape the result. Then build report's lines as t-strings and pass them through render.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    from string.templatelib import convert: value = convert(item.value, item.conversion).

  2. Hint 2

    Then escape(format(value, item.format_spec)), with escape from html.

  3. Hint 3

    In report, write render(t"<li>{name}: {score:>3}</li>"): only the values are escaped, not your own tags.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from html import escape
from string.templatelib import Interpolation, Template, convert


def render(template: Template) -> str:
    """Join the template, HTML-escaping every interpolated value."""
    parts: list[str] = []
    for item in template:
        if isinstance(item, Interpolation):
            value = convert(item.value, item.conversion)
            parts.append(escape(format(value, item.format_spec)))
        else:
            parts.append(item)
    return "".join(parts)


def report(title: str, rows: list[tuple[str, int]]) -> str:
    """Return an HTML report: a heading and one list item per row."""
    lines = [render(t"<h1>{title}</h1>")]
    for name, score in rows:
        lines.append(render(t"<li>{name}: {score:>3}</li>"))
    return "\n".join(lines)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from string.templatelib import Interpolation, Template


def render(template: Template) -> str:
    """Join the template, HTML-escaping every interpolated value."""
    return "".join(
        str(item.value) if isinstance(item, Interpolation) else item for item in template
    )


def report(title: str, rows: list[tuple[str, int]]) -> str:
    """Return an HTML report: a heading and one list item per row."""
    lines = [f"<h1>{title}</h1>"]
    for name, score in rows:
        lines.append(f"<li>{name}: {score:>3}</li>")
    return "\n".join(lines)

test_main.py

from main import render, report


def test_render_escapes():
    """Interpolated values are HTML-escaped"""
    x = "<script>"
    got = render(t"<p>{x}</p>")
    assert got == "<p>&lt;script&gt;</p>", f"render gave {got!r}"


def test_render_format_spec():
    """The format spec is applied"""
    n = 7
    got = render(t"[{n:>4}]")
    assert got == "[   7]", f"render gave {got!r}; apply format(value, item.format_spec)"


def test_render_conversion():
    """!r is applied, then escaped"""
    v = "a<b"
    got = render(t"{v!r}")
    assert got == "&#x27;a&lt;b&#x27;", f"render gave {got!r}; apply convert(value, item.conversion)"


def test_report():
    """The report escapes the title and every name"""
    got = report("Scores <2026>", [("ada", 3), ("<b>bob</b>", 12)])
    want = "<h1>Scores &lt;2026&gt;</h1>\n<li>ada:   3</li>\n<li>&lt;b&gt;bob&lt;/b&gt;:  12</li>"
    assert got == want, f"report gave {got!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 4 of 4

Build step 4: check pyproject.toml before building

Before running python3 -m build, check the project file. package_info(text) should return "name version" and raise ValueError unless there is a [build-system] table with a requires list and a build-backend string, and a [project] table whose name and version are strings. The starter crashes with KeyError or TypeError instead.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Use data.get("project") and data.get("build-system"), and check each is a dict.

  2. Hint 2

    Check isinstance(name, str) and isinstance(version, str); version = 1 in TOML is an int.

  3. Hint 3

    Check isinstance(system.get("requires"), list) and isinstance(system.get("build-backend"), str).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import tomllib


def package_info(text: str) -> str:
    """Check a pyproject.toml before building; return "name version"."""
    data = tomllib.loads(text)
    project = data.get("project")
    system = data.get("build-system")
    if not isinstance(project, dict) or not isinstance(system, dict):
        raise ValueError("need a [project] and a [build-system] table")
    name = project.get("name")
    version = project.get("version")
    if not isinstance(name, str) or not isinstance(version, str):
        raise ValueError("[project] needs name and version as strings")
    if not isinstance(system.get("requires"), list) or not isinstance(system.get("build-backend"), str):
        raise ValueError("[build-system] needs a requires list and a build-backend string")
    return f"{name} {version}"
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import tomllib


def package_info(text: str) -> str:
    """Check a pyproject.toml before building; return "name version"."""
    data = tomllib.loads(text)
    return data["project"]["name"] + " " + data["project"]["version"]

test_main.py

from main import package_info

GOOD = """
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"

[project]
name = "example_package"
version = "0.0.1"
"""

NO_BUILD_SYSTEM = """
[project]
name = "example_package"
version = "0.0.1"
"""

NUMBER_VERSION = """
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"

[project]
name = "example_package"
version = 1
"""

NO_PROJECT = """
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"
"""


def rejects(text):
    try:
        package_info(text)
    except ValueError:
        return True
    return False


def test_good():
    """A complete pyproject.toml gives its name and version"""
    got = package_info(GOOD)
    assert got == "example_package 0.0.1", f"package_info gave {got!r}"


def test_needs_build_system():
    """A missing [build-system] table is refused"""
    assert rejects(NO_BUILD_SYSTEM), "a file without [build-system] was accepted or raised the wrong error"


def test_version_is_string():
    """A version that is a number is refused"""
    assert rejects(NUMBER_VERSION), "version = 1 was accepted or raised the wrong error; it must be a string"


def test_needs_project():
    """A missing [project] table is refused with ValueError"""
    assert rejects(NO_PROJECT), "a file without [project] was accepted or raised the wrong error"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Hashing a str

import hashlib

digest = hashlib.sha256("report,2026").hexdigest()

What Python prints

TypeError: Strings must be encoded before hashing

Why, and the fix

Hash functions work on bytes, and the same text can be encoded in different ways. Encode explicitly, usually "report,2026".encode() (UTF-8), so every program that checks the digest hashes the same bytes.

hmac.new without a hash algorithm

import hmac

tag = hmac.new(b"server-key", b"user=ada").hexdigest()

What Python prints

TypeError: Missing required argument 'digestmod'.

Why, and the fix

digestmod is required: pass the algorithm name as the third argument, hmac.new(key, msg, "sha256"), or as digestmod=hashlib.sha256. There is no default, so the choice is always visible in the code.

A str password for pbkdf2_hmac

import hashlib
import secrets

salt = secrets.token_bytes(16)
key = hashlib.pbkdf2_hmac("sha256", "hunter2", salt, 100_000)

What Python prints

TypeError: a bytes-like object is required, not 'str'

Why, and the fix

pbkdf2_hmac reads the password and the salt as bytes. Encode the password first: "hunter2".encode(). The salt from secrets.token_bytes is already bytes.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Tokens from secrets, checksums from hashlib

The random module is built for simulation; the docs say to prefer secrets for anything security-related. secrets.token_urlsafe(n) returns n random bytes as URL-safe text (about 1.3 characters per byte), token_hex(n) returns 2n hex digits, and with no argument both use a default the docs say may change; 32 bytes is their current advice. hashlib.sha256(data) takes bytes, so encode text first. The digest has a fixed size, 32 bytes or 64 hex digits, whatever the input, and the same bytes always give the same digest: good for checksums, but anyone can compute it, so it proves nothing about who made the data.

Signatures with hmac; passwords with a slow, salted hash

hmac.new(key, msg, "sha256") mixes a secret key into the hash, so only key holders can make a matching tag; digestmod is required. Compare tags with hmac.compare_digest, never ==: it avoids content-based short-circuiting, so timing does not reveal how much of a guess was right. It takes two str (ASCII only) or two bytes. For passwords, a plain hash is too fast to resist guessing. hashlib.pbkdf2_hmac takes a random salt and an iteration count, and you store both with the result. Pyodide has no pbkdf2_hmac, so that exercise runs on your own computer.

Publishing with build and twine; the module build

The packaging tutorial describes the steps. pyproject.toml names a build backend in [build-system], with requires and build-backend, and the package metadata in [project]. python3 -m build writes two files to dist/: a .tar.gz source distribution and a .whl built distribution. twine upload sends them, first to TestPyPI, a separate index for testing, using an API token. Here you only check pyproject.toml with tomllib; nothing is uploaded. The build pulls the module together: a report generator whose render function escapes every value of a t-string, so names typed by users cannot inject HTML.

Sources

Last reviewed September 29, 2026