Applied Cryptography for Developers Module 11 – Tokens, OTPs and passkeys
Bearer secrets: reset links, API keys and CSRF
Make reset, API and session tokens with 128 random bits, store only a hash, end links after one use, format keys for scanners and bind CSRF tokens to sessions.
What you will learn
- Generate bearer secrets with at least 128 bits from a CSPRNG and a format that scanners can recognise
- Store only a hash of each token and verify it in constant time with a selector and a verifier
- Build reset-link tokens that expire in minutes and work once
- Choose between opaque, signed and encrypted session tokens for a given revocation need
- Derive a CSRF token from the session ID with HMAC and check it
Before you start
What you’ll learn · Before you start
What you will learn
- Generate bearer secrets with at least 128 bits from a CSPRNG and a format that scanners can recognise
- Store only a hash of each token and verify it in constant time with a selector and a verifier
- Build reset-link tokens that expire in minutes and work once
- Choose between opaque, signed and encrypted session tokens for a given revocation need
- Derive a CSRF token from the session ID with HMAC and check it
Before you start
On this page
A password-reset email holds a link, and whoever opens it becomes the account’s owner for the next few minutes. If the token in the link is eight hex digits, that is 2³² values, and a script making 10,000 guesses a second finds one of 100,000 pending tokens in about four seconds. With 128 random bits the same script would need about 10²² years.
A token like this is a bearer token: A secret that works for whoever presents it: the server checks the text, not who sends it. Reset links, API keys and session cookies are bearer tokens, so anyone who copies one can use it.: it works for anyone who holds it. This lesson follows one from its first byte to its last use: how many bits it needs, what the database keeps, how a reset link ends, a key format that scanners can find, which session cookie to choose and a CSRF token that the server recomputes. Every design runs on this page, and each weak version fails a check that the fix passes.
A token is as strong as its random bits, and 128 is the floor
RFC 6750 defines a bearer token as one that any party in possession of it can use in any way that any other party in possession of it can. The server checks the text of the token and nothing about who sent it, so the only protection a bearer secret has against guessing is the number of random bits behind it. ASVS 5.0 requirement 11.5.1 sets the floor: every value meant to be non-guessable comes from a CSPRNG: A cryptographically secure pseudo-random number generator: no practical computation on the bits it has given predicts its next bit noticeably better than a coin toss. Operating systems run one, seeded from an entropy source. More in “Secure random numbers in browsers, Node and Python” and carries at least 128 bits of entropy (of a token): The number of bits an attacker has to guess. A token of L characters, each drawn uniformly and independently from an alphabet of n, carries L × log2(n) bits; a fixed part such as a prefix carries none. More in “Random tokens, UUIDs and unbiased sampling”. OAuth is stricter still. RFC 6749 section 10.10 asks that the chance of guessing a generated credential be at most 2⁻¹²⁸ and preferably at most 2⁻¹⁶⁰.
A UUID does not qualify, not even a random one. A version 4 UUID has 122 random bits, and RFC 9562 says implementations must not use UUIDs as security capabilities, identifiers whose mere possession grants access.
A token that only looks random
Wrong: Math.random().toString(36), a timestamp, a counter or a UUID as a reset token or an API key. Right: 16 or more
bytes (32 for a key that lives long) from crypto.randomBytes in Node.js, crypto.getRandomValues in a browser or
secrets.token_bytes in Python, written as hex or Base64url.
Bits are not characters, and every valid token makes a guess easier. The program prints how many characters carry 128 bits in four encodings, then the time a guesser needs when 100,000 tokens are valid at once and 10,000 guesses are made a second, the setting of OWASP’s own session-ID example:
// How many random bits does a bearer secret need? Three tables, all arithmetic: the same 128 bits written in four
// encodings, the time a guesser needs to hit one of many valid tokens, and what a six-digit code allows. No token is made.
const YEAR = 365.25 * 24 * 3600;
const sci = (x) => x.toExponential(1).replace('e+', 'e');
const guesses = (x) => (x < 1e6 ? Math.round(x).toLocaleString('en-US') : sci(x));
function duration(seconds) {
if (seconds < 120) return `${seconds.toFixed(1)} seconds`;
if (seconds < 7200) return `${(seconds / 60).toFixed(1)} minutes`;
if (seconds < 2 * 86400) return `${(seconds / 3600).toFixed(1)} hours`;
if (seconds < 2 * YEAR) return `${(seconds / 86400).toFixed(1)} days`;
const years = seconds / YEAR;
return years < 1e4 ? `${Math.round(years).toLocaleString('en-US')} years` : `${sci(years)} years`;
}
// Characters needed for 128 bits: each character carries log2(alphabet size) bits.
console.log('Characters needed for 128 bits');
for (const [name, size] of [['hex', 16], ['Base32', 32], ['Base64url', 64], ['decimal digits', 10]]) {
const chars = Math.ceil(128 / Math.log2(size));
console.log(` ${name.padEnd(15)} ${String(chars).padStart(2)} characters = ${(chars * Math.log2(size)).toFixed(1)} bits`);
}
// A guess hits when it equals any one of the valid tokens, so v valid tokens cut the expected work by a factor v.
const expected = (bits, valid) => 2 ** bits / valid;
console.log('');
console.log('Time to the first hit with 100,000 valid tokens and 10,000 guesses a second');
console.log(' bits guesses needed time');
for (const bits of [32, 40, 64, 96, 128]) {
const g = expected(bits, 100_000);
console.log(` ${String(bits).padStart(4)} ${guesses(g).padStart(14)} ${duration(g / 10_000)}`);
}
console.log('');
console.log('A faster guesser, and a longer token');
console.log(` 64 bits at 10 million guesses a second: ${duration(expected(64, 100_000) / 1e7)}`);
console.log(` 128 bits at 10 trillion guesses a second: ${duration(expected(128, 100_000) / 1e13)}`);
console.log('');
console.log('A six-digit emailed code carries 20 bits, so attempts must be limited');
for (const attempts of [1, 5, 100]) {
console.log(` ${String(attempts).padStart(3)} attempts allowed: chance of a hit ${((attempts / 1e6) * 100).toFixed(4)} %`);
} Output
Characters needed for 128 bits
hex 32 characters = 128.0 bits
Base32 26 characters = 130.0 bits
Base64url 22 characters = 132.0 bits
decimal digits 39 characters = 129.6 bits
Time to the first hit with 100,000 valid tokens and 10,000 guesses a second
bits guesses needed time
32 42,950 4.3 seconds
40 1.1e7 18.3 minutes
64 1.8e14 585 years
96 7.9e23 2.5e12 years
128 3.4e33 1.1e22 years
A faster guesser, and a longer token
64 bits at 10 million guesses a second: 213.5 days
128 bits at 10 trillion guesses a second: 1.1e13 years
A six-digit emailed code carries 20 bits, so attempts must be limited
1 attempts allowed: chance of a hit 0.0001 %
5 attempts allowed: chance of a hit 0.0005 %
100 attempts allowed: chance of a hit 0.0100 %
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node guess_time.mjs
128 bits are 32 hex digits, 26 Base32 characters or 22 Base64url characters. The second table reproduces OWASP’s number: with 64 bits the first hit takes about 585 years. Against a guesser a thousand times faster the same 64 bits last 213 days, and a 32-bit token gave way in 4.3 seconds. With 128 bits even a guesser a billion times faster than OWASP’s needs about 10¹³ years. That margin absorbs a faster guesser, a bigger table of valid tokens and a generator that turns out weaker than it should be.
The last table is the exception. A six-digit emailed code has only 20 bits, which ASVS accepts for out-of-band codes (requirement 6.5.4) if they live at most 10 minutes (6.5.5) and attempts are rate-limited (6.6.3). Its safety comes from the limit, not from the bits: five attempts leave a 0.0005 % chance.
Key idea
Count the bits, not the characters: a bearer secret needs at least 128 random bits from a CSPRNG, and only a code with a short life and a limit on attempts may have fewer.
Checkpoint
Check: How many random bits a token needs
Store a hash of the token and find the row by a selector
A database is read more often than it is attacked on purpose: through a backup, a replica, a debugging export, a SQL injection that can read but not write. If the table of pending links holds the tokens, each of those reads hands over working links, so store a hash of each token.
A fast hash is enough, which surprises readers who have just learned that passwords need Argon2id. A person chooses a password from a short list of likely ones, so a thief hashes the list. A token is 128 or more random bits, so there is no list to hash and the thief has to search all the values. ASVS requirement 6.5.2 says it directly: a lookup secret with at least 112 bits of entropy may use a standard hash function, and a shorter one needs a salted password hash. OWASP’s Session Management Cheat Sheet adds that password hashing algorithms are unnecessary for random verifiers.
The design that cheat sheet describes splits the token in two. The selector and verifier: A token in two parts joined by a dot. The selector is public and finds the stored row; the verifier is secret, and only its hash is stored and compared. The selector alone is never enough. is a public name for the row, and the verifier is the secret. The link carries both. The server stores the selector and the SHA-256 of the verifier. On every request it looks the row up by selector, hashes the presented verifier and compares the two digests in constant-time comparison: A comparison of secret values whose running time does not depend on where they differ, because it reads every byte instead of stopping at the first difference. Python's hmac.compare_digest and Node's crypto.timingSafeEqual provide one. More in “HMAC and constant-time verification”. The selector alone is never accepted, and the two parts are drawn independently, so only the verifier counts towards the bits. Step through one link:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Two independent draws. The server draws 12 random bytes for the selector and 32 more for the verifier, as two separate calls to the CSPRNG. The selector is only a name for the row, so it says nothing about the verifier.
- Write the hash, not the token. The row holds the selector, the user, an expiry 10 minutes ahead and the SHA-256 of the verifier. The verifier itself is never written down, so a copy of the table contains no working link.
- Email the whole token. The person receives selector.verifier, 16 + 1 + 43 characters, in a link over HTTPS. This is the only place the verifier exists, apart from the server's memory during this request.
- The link comes back. The browser sends the whole token. The server splits it at the dot. A token without exactly one dot is refused before anything is looked up.
- Look up by the selector. Only the selector goes into the query. It is not a secret, so it can sit in an index, a log line and an admin screen without harm.
- The row comes back. The row has a hash, an expiry and a used mark. An unknown selector gets the same refusal as a wrong verifier, so the reply tells a guesser nothing.
- Hash and compare. The server hashes the presented verifier and compares the two digests in constant time, then checks that now is before the expiry and that the row is unused. Any failed check gives the same answer.
- Use it up. The row is deleted, or marked used, in the same step as the check, so a second request with the same link finds nothing. Then the person sets a new password.
The program runs that design with a repeatable stand-in for the random source, so its output is the same on every run:
// A reset link token in two parts. The selector finds the row; only a hash of the verifier is stored, and the
// verifier is compared with that hash in constant time. The byte source is injected so this run is repeatable;
// production code passes crypto.randomBytes.
import { createHash, timingSafeEqual } from 'node:crypto';
const sha256 = (bytes) => createHash('sha256').update(bytes).digest();
const b64url = (bytes) => Buffer.from(bytes).toString('base64url');
// 12 selector bytes (96 bits, public) and 32 verifier bytes (256 bits, secret): 16 and 43 characters.
function issue(db, userId, now, randomBytes) {
const selector = b64url(randomBytes(12));
const verifier = b64url(randomBytes(32));
db.set(selector, { userId, verifierHash: sha256(verifier), expiresAt: now + 10 * 60, usedAt: null });
return `${selector}.${verifier}`;
}
// Every refusal looks the same to the caller: wrong format, unknown selector and wrong verifier give one answer.
function redeem(db, token, now) {
const [selector, verifier, ...rest] = String(token).split('.');
if (!selector || !verifier || rest.length) return 'invalid';
const row = db.get(selector);
const expected = row ? row.verifierHash : Buffer.alloc(32); // hash and compare anyway: both paths do the same work
const same = timingSafeEqual(sha256(verifier), expected);
if (!row || !same || row.usedAt !== null || now >= row.expiresAt) return 'invalid';
row.usedAt = now;
return `ok: user ${row.userId}`;
}
// A repeatable stand-in for the CSPRNG: every call returns the next bytes of a SHA-256 stream, never the same twice.
function demoSource(label) {
let n = 0;
return (length) => {
const out = [];
while (out.length < length) out.push(...createHash('sha256').update(`${label}:${n++}`).digest());
return Buffer.from(out.slice(0, length));
};
}
const source = demoSource('demo bytes, not random');
const db = new Map();
const token = issue(db, 42, 1_000, source);
const [selector, verifier] = token.split('.');
const row = db.get(selector);
console.log(`token ${token}`);
console.log(`selector ${selector} (${selector.length} characters, public: safe to log)`);
console.log(`verifier ${verifier} (${verifier.length} characters, secret)`);
console.log(`stored selector ${selector}, SHA-256 of the verifier ${row.verifierHash.toString('hex').slice(0, 16)}…`);
console.log('');
console.log(`wrong verifier ${redeem(db, `${selector}.${b64url(source(32))}`, 1_100)}`);
console.log(`unknown selector ${redeem(db, `${b64url(source(12))}.${verifier}`, 1_100)}`);
console.log(`selector alone ${redeem(db, selector, 1_100)}`);
console.log(`stored hash as verifier ${redeem(db, `${selector}.${b64url(row.verifierHash)}`, 1_100)}`);
console.log(`the real link ${redeem(db, token, 1_100)}`);
console.log(`the real link again ${redeem(db, token, 1_101)}`);
const late = issue(db, 7, 2_000, source);
console.log(`a second link, 11 minutes later ${redeem(db, late, 2_000 + 11 * 60)}`); Output
token TgRfhjizfmCLMBbX.KlAIY8oyoxpfaWu41evjyLSLIi0IkShz40xJ4YhCBS4 selector TgRfhjizfmCLMBbX (16 characters, public: safe to log) verifier KlAIY8oyoxpfaWu41evjyLSLIi0IkShz40xJ4YhCBS4 (43 characters, secret) stored selector TgRfhjizfmCLMBbX, SHA-256 of the verifier db22b437f0370c96… wrong verifier invalid unknown selector invalid selector alone invalid stored hash as verifier invalid the real link ok: user 42 the real link again invalid a second link, 11 minutes later invalid
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node selector_verifier.mjs
The selector has 16 characters and is safe to log and to index, because nothing about the secret reaches a query or a log line. Four attempts are refused with the same answer: a wrong verifier, an unknown selector, a selector alone, and the stored hash presented as a verifier, which hashes to something else. That last line is what the hash is for. A thief who holds the row still has to find a 256-bit input that hashes to it. What a hash cannot protect is the token on its way: the link in the email, a request log that records addresses, a browser extension.
Keeping the token in the table
Wrong: store the token in a column and look it up with WHERE token = ?. One leaked backup is then a set of working
links. Right: look up WHERE selector = ?, hash the presented verifier and compare the digests with timingSafeEqual
in Node.js or hmac.compare_digest in Python.
Checkpoint
Check: Storing a hash and finding the row by a selector
A reset link lives for minutes and works once
A reset link is a password to one account that arrives in an inbox, so most of what can go wrong is about time and state. ASVS asks that activation codes expire after a short time or after first use (requirement 6.4.1) and that recovery and out-of-band codes work only once (6.5.1). An out-of-band code may live at most 10 minutes (6.5.5). OWASP’s Forgot Password Cheat Sheet lists the habits that go with these rules. Follow one link through its six stages:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The request. Someone types an email address. The reply and its timing are the same whether or not an account exists, and requests are limited per account, so the form cannot be used to find out who has an account or to flood an inbox.
- Issue. For a known account the server draws a selector and a verifier, stores the selector and a hash of the verifier with an expiry of 10 minutes, and deletes the account's older links. The account itself is not locked or changed yet.
- Email. The link is built from a fixed, configured address over HTTPS, never from the request's Host header, which a sender can set. It travels by email, a separate channel from the web page that asked for it.
- Open. The page that receives the link sends Referrer-Policy: no-referrer. The server checks the token, then trades it for a short session that can only set a password and redirects to a URL without the token, so it does not stay in the address bar or in logs.
- Set a password. The new password goes through the same policy and the same slow hash as every other password. The person types it twice and is not signed in automatically, which keeps the reset code away from the session code.
- Clean up. All open reset links of the account end, the old sessions end or are offered for ending, and an email tells the owner the password changed. Without the cleanup, a link requested earlier by someone else still works.
The next program runs the same eight checks against two stores. WeakResets seeds a generator with the clock, keeps
tokens as text, never expires them and never ends them: it is a toy made for this page, with flaws that code reviews keep
finding. FixedResets is the design above. Run it, then change TTL or remove the end_all call and watch which
checks fail:
"""Two password-reset stores and the same eight checks. WeakResets is a toy made for this page; FixedResets is the design."""
import base64
import hashlib
import hmac
import random
import secrets
TTL = 10 * 60 # a reset link lives 10 minutes
def b64url(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
class WeakResets:
"""Seeds a generator with the clock, keeps the token as text, never expires it and never ends it."""
def __init__(self):
self.rows = {} # token -> user
def issue(self, user, now):
token = f"{random.Random(int(now)).getrandbits(32):08x}" # 32 bits, a function of the second
self.rows[token] = user
return token
def redeem(self, token, now):
return self.rows.get(token)
def change_password(self, user):
pass
def stored_text(self):
return " ".join(self.rows)
def secret_part(self, token):
return token
class FixedResets:
"""A selector finds the row, only a hash of the verifier is stored, links expire and work once."""
def __init__(self):
self.rows = {} # selector -> row
def issue(self, user, now):
self.end_all(user) # a newer request ends the older links
selector = b64url(secrets.token_bytes(12))
verifier = b64url(secrets.token_bytes(32))
self.rows[selector] = {"user": user, "hash": hashlib.sha256(verifier.encode()).digest(), "expires": now + TTL}
return f"{selector}.{verifier}"
def redeem(self, token, now):
selector, _, verifier = token.partition(".")
row = self.rows.get(selector)
if row is None or not verifier:
return None
same = hmac.compare_digest(hashlib.sha256(verifier.encode()).digest(), row["hash"])
if not same or now >= row["expires"]:
return None
del self.rows[selector] # single use: the row goes in the same step as the check
return row["user"]
def end_all(self, user):
self.rows = {s: r for s, r in self.rows.items() if r["user"] != user}
def change_password(self, user):
self.end_all(user)
def stored_text(self):
return " ".join(f"{s} {r['hash'].hex()}" for s, r in self.rows.items())
def secret_part(self, token):
return token.partition(".")[2]
def clock_guess(now):
"""What someone who knows only the second of the request can compute."""
return f"{random.Random(int(now)).getrandbits(32):08x}"
def not_from_the_clock(store):
return store.issue("asha", 1_000) != clock_guess(1_000)
def nothing_usable_stored(store):
token = store.issue("asha", 1_000)
return store.secret_part(token) not in store.stored_text()
def works_once(store):
return store.redeem(store.issue("asha", 1_000), 1_001) == "asha"
def second_use_refused(store):
token = store.issue("asha", 1_000)
store.redeem(token, 1_001)
return store.redeem(token, 1_002) is None
def expires_at_ten_minutes(store):
early = store.redeem(store.issue("asha", 1_000), 1_000 + TTL - 1)
late = store.redeem(store.issue("ravi", 1_000), 1_000 + TTL)
return early == "asha" and late is None
def changed_token_refused(store):
return store.redeem(store.issue("asha", 1_000)[:-1] + "x", 1_001) is None
def newer_request_ends_older(store):
older = store.issue("asha", 1_000)
store.issue("asha", 1_001)
return store.redeem(older, 1_002) is None
def password_change_ends_links(store):
token = store.issue("asha", 1_000)
store.change_password("asha")
return store.redeem(token, 1_002) is None
CHECKS = [
("the token cannot be derived from the clock", not_from_the_clock),
("the store holds no usable token", nothing_usable_stored),
("a link works once", works_once),
("the second use is refused", second_use_refused),
("it works at 9:59 and not at 10:00", expires_at_ten_minutes),
("a changed token is refused", changed_token_refused),
("a newer request ends the older link", newer_request_ends_older),
("a password change ends open links", password_change_ends_links),
]
weak_passed = fixed_passed = 0
print(f"{'check':44} {'weak':>5} {'fixed':>6}")
for name, check in CHECKS:
weak, fixed = check(WeakResets()), check(FixedResets())
weak_passed, fixed_passed = weak_passed + weak, fixed_passed + fixed
print(f"{name:44} {'ok' if weak else 'FAIL':>5} {'ok' if fixed else 'FAIL':>6}")
print(f"{'passed':44} {weak_passed:>3}/8 {fixed_passed:>4}/8") Output
check weak fixed the token cannot be derived from the clock FAIL ok the store holds no usable token FAIL ok a link works once ok ok the second use is refused FAIL ok it works at 9:59 and not at 10:00 FAIL ok a changed token is refused ok ok a newer request ends the older link FAIL ok a password change ends open links FAIL ok passed 2/8 8/8
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reset_flow.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 weak store passed 2 of 8 checks and the fixed store passed all 8. The two the weak store passed, that a link works once and that a changed token is refused, show why one successful click proves nothing: the weak store works perfectly for the person it was built for. The first check is the worst. Anyone who knows the second at which a link was requested can recompute its 32 bits, because the generator was seeded with the clock.
Three details of the flow are easy to miss. A link in an address leaks to anything that sees the address, so the reset
page sends Referrer-Policy: no-referrer, and the server trades the token for a short session that can only set a
password and redirects to an address without the token, which OWASP calls creating a restricted session from the token.
The link is built from a fixed, configured address and never from the Host header, which the sender of a request
controls. And the reply to a request must not tell a visitor whether an address has an account: the same message in the
same time, limits per account, and no lockout of the account itself, because a lockout lets anyone who knows an address
block its owner. Log the selector for support work and never the verifier.
Checkpoint
Check: A reset link that lives minutes and works once
A key format that scanners can recognise
Reset links die in minutes. API keys live for years in environment files, CI settings, logs and screenshots, and some
will leak, so how much damage a leak does depends on how soon it is found. RFC 8959 registers a secret-token URI scheme
so that tokens are easy to recognise, and tools can then warn about or refuse a commit that contains one. GitHub’s token
formats use the same idea with a prefix that names the kind of token and a CRC-32 checksum in the last six characters,
so that a scanner can reject look-alikes offline.
Not affiliated
Based on public information about GitHub. MySmartCoPilot is not affiliated with GitHub, and GitHub’s real token formats may differ from what this lesson describes.
The format in the next program has three parts. demo_live_ says what the string is and in which environment, so a
secret scanning: Software that searches repositories, logs and messages for leaked secrets by their shape, such as a known prefix and a valid checksum, so that a leak can be found and the key revoked. has a pattern to look for and a person who finds the key in a log knows which
service to call. Then come 43 Base62 characters made from 32 random bytes, 256 bits. Last come six characters, the CRC-32
of everything before them. The server keeps none of it: it stores the SHA-256 of the whole key to look the key up by,
and the prefix and the last four characters to show in a list.
"""An API key with a prefix, 256 random bits and a CRC-32 checksum; what the server keeps; and a log scanner."""
import hashlib
import re
import zlib
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
PREFIX = "demo_live_"
BODY, CHECK = 43, 6 # 62**43 is more than 2**256, and 62**6 is more than 2**32
def base62(number, width):
digits = []
for _ in range(width):
number, digit = divmod(number, 62)
digits.append(ALPHABET[digit])
return "".join(reversed(digits))
def checksum(text):
return base62(zlib.crc32(text.encode()), CHECK)
def mint(random_bytes):
body = base62(int.from_bytes(random_bytes(32), "big"), BODY)
return PREFIX + body + checksum(PREFIX + body)
def has_valid_checksum(key):
return key[-CHECK:] == checksum(key[:-CHECK])
def record_for(key):
"""What the server stores: never the key, only a digest to look it up by and enough to show it in a list."""
return {"prefix": PREFIX, "last4": key[-4:], "sha256": hashlib.sha256(key.encode()).hexdigest()}
# A repeatable stand-in for the CSPRNG: the bytes of a SHA-256 chain, never the same twice.
chain = hashlib.sha256(b"demo bytes, not random").digest()
def demo_bytes(length):
global chain
chain = hashlib.sha256(chain).digest()
return chain[:length]
key = mint(demo_bytes)
print(f"key {key} ({len(key)} characters)")
print(f"parts prefix {PREFIX}, {BODY} random characters (256 bits from 32 bytes), checksum {key[-CHECK:]}")
print(f"stored {record_for(key)}")
# A scanner finds anything shaped like a key, then keeps only the strings whose checksum is right.
SCANNER = re.compile(rf"demo_(?:live|test)_[0-9A-Za-z]{{{BODY + CHECK}}}(?![0-9A-Za-z])")
look_alike = key[:-1] + ("0" if key[-1] != "0" else "1") # the same key with its last character changed
lines = [
f"GET /v1/items 200 api_key={key}",
f"retrying with {look_alike}",
"GET /v1/items 200 request_id=7f3a",
]
print()
for line in lines:
found = SCANNER.findall(line)
if not found:
print("scanner no key in this line")
for candidate in found:
verdict = "valid checksum: report it and revoke the key" if has_valid_checksum(candidate) else "checksum fails: drop it, no call to the issuer"
print(f"scanner {candidate[:14]}…{candidate[-4:]} {verdict}") Output
key demo_live_KjaSPwuBQ09WXl2VxjaLrJvVCJq2oT8vu4ryIpKL6it1EuvH2 (59 characters)
parts prefix demo_live_, 43 random characters (256 bits from 32 bytes), checksum 1EuvH2
stored {'prefix': 'demo_live_', 'last4': 'uvH2', 'sha256': '6793af7c81428b22604a2f54071d653c2869912e5611a1100a630cbf55beedc0'}
scanner demo_live_KjaS…uvH2 valid checksum: report it and revoke the key
scanner demo_live_KjaS…uvH0 checksum fails: drop it, no call to the issuer
scanner no key in this line
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 api_key.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 scanner found the real key by its shape and checked the checksum, which passed, so the right action is to report it and revoke the key. It found a look-alike with one character changed, whose checksum failed, and dropped it without asking anyone. The checksum is no security feature. Anyone can compute a CRC-32, so a key with a valid checksum is not a valid key, and the server still looks the hash up. It makes false alarms cheap to dismiss, so that the alarms which remain are worth waking someone for.
API Key & Secret Generator Make a 256-bit Base62 key with a prefix and a CRC-32 checksum in your browser, then check a key's checksum as the scanner above does.Checkpoint
Check: Key formats that scanners can recognise
Opaque, signed or encrypted: three ways to keep a session
After sign-in the browser sends a session token with every request, and there are three common ways to build one. An opaque session token: A random session ID that means nothing by itself. All the session's data stays on the server, which ends the session by deleting the row. is a random ID, with the session’s data kept on the server. A signed cookie carries the data and an HMAC: The MAC of RFC 2104 and FIPS 198-1, built from a hash H as H((K ⊕ opad) ∥ H((K ⊕ ipad) ∥ message)), with the key padded to one block, ipad the byte 0x36 and opad the byte 0x5c. HMAC-SHA-256 gives a 32-byte tag. More in “HMAC and constant-time verification” tag that detects changes. An encrypted cookie carries the data under an authenticated cipher such as AES-GCM, which hides it and detects changes. The last two are self-contained token: A token that carries its own data and protects it with a signature, a MAC or encryption, so a server can accept it without a lookup. The server cannot take it back before it expires.. The program builds all three from the same data and measures what each one does:
// The same session in three cookies: an opaque ID, a signed value and an encrypted value. For each: how big the cookie
// is, whether the person holding it can read it, what a changed cookie does, and what a logout on the server does.
import { createCipheriv, createDecipheriv, createHash, createHmac, timingSafeEqual } from 'node:crypto';
const b64url = (bytes) => Buffer.from(bytes).toString('base64url');
const fromB64url = (text) => Buffer.from(text, 'base64url');
const data = JSON.stringify({ sub: 'u_1042', role: 'editor', exp: 1_800 });
// A repeatable stand-in for the CSPRNG (SHA-256 of a counter); production code uses crypto.randomBytes.
let n = 0;
const demoBytes = (length) => createHash('sha256').update(`demo ${n++}`).digest().subarray(0, length);
const signingKey = demoBytes(32);
const encryptionKey = demoBytes(32); // a second key: one key is never used for two jobs
// 1. Opaque: the cookie is only a name; the data stays in the server's table.
const table = new Map();
const opaqueId = b64url(demoBytes(24));
table.set(opaqueId, data);
const opaqueOpen = (cookie) => table.get(cookie) ?? null;
// 2. Signed: the data travels in the cookie, readable, and an HMAC says nobody changed it.
const mac = (payload) => createHmac('sha256', signingKey).update(payload).digest();
const signedCookie = `${b64url(data)}.${b64url(mac(b64url(data)))}`;
function signedOpen(cookie) {
const [payload, tag, ...rest] = cookie.split('.');
if (!payload || !tag || rest.length) return null;
const given = fromB64url(tag);
const want = mac(payload);
return given.length === want.length && timingSafeEqual(given, want) ? fromB64url(payload).toString() : null;
}
// 3. Encrypted: AES-256-GCM hides the data and detects changes. The 12-byte nonce is new for every cookie.
function seal(text) {
const nonce = demoBytes(12);
const cipher = createCipheriv('aes-256-gcm', encryptionKey, nonce);
const body = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
return b64url(Buffer.concat([nonce, body, cipher.getAuthTag()]));
}
function encryptedOpen(cookie) {
const raw = fromB64url(cookie);
if (raw.length < 12 + 16) return null;
try {
const decipher = createDecipheriv('aes-256-gcm', encryptionKey, raw.subarray(0, 12));
decipher.setAuthTag(raw.subarray(raw.length - 16));
return Buffer.concat([decipher.update(raw.subarray(12, raw.length - 16)), decipher.final()]).toString();
} catch {
return null;
}
}
const encryptedCookie = seal(data);
const flip = (cookie) => cookie.slice(0, 10) + (cookie[10] === 'A' ? 'B' : 'A') + cookie.slice(11);
const readable = (cookie) => (fromB64url(cookie.split('.')[0]).toString().startsWith('{') ? 'yes' : 'no');
const designs = [
['opaque', opaqueId, opaqueOpen],
['signed', signedCookie, signedOpen],
['encrypted', encryptedCookie, encryptedOpen],
];
const verdict = (open, cookie, accepted) => (open(cookie) === null ? 'refused' : accepted);
console.log(`the data: ${data} (${data.length} characters)`);
console.log('');
console.log('design cookie size holder can read it one changed character after logout on the server');
const changed = designs.map(([, cookie, open]) => verdict(open, flip(cookie), 'ACCEPTED'));
table.delete(opaqueId); // the logout, on the server: for the opaque design this one line is the whole revocation
designs.forEach(([name, cookie, open], i) => {
const size = `${String(cookie.length).padStart(4)} chars`;
console.log(`${name.padEnd(10)} ${size} ${readable(cookie).padEnd(20)} ${changed[i].padEnd(23)} ${verdict(open, cookie, 'still accepted')}`);
}); Output
the data: {"sub":"u_1042","role":"editor","exp":1800} (43 characters)
design cookie size holder can read it one changed character after logout on the server
opaque 32 chars no refused refused
signed 102 chars yes refused still accepted
encrypted 95 chars no refused still accepted
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node session_cookies.mjs
The same 43 characters of data became cookies of 32, 102 and 95 characters. The holder can read the signed cookie, because Base64url is an encoding and not a secret. A changed character is refused by all three. The last column separates them: once the server deletes the opaque row the cookie is refused at once, while the signed and encrypted cookies are still accepted, because nothing on the server was consulted. ASVS requirement 7.4.1 states the consequence. A self-contained token needs a list of ended tokens, a per-user cut-off time or a rotated signing key before a logout really ends the session. OWASP adds that the content of a session ID should be meaningless, with its meaning kept on the server, and that an ID you create yourself needs at least 128 bits (ASVS 7.2.3 says the same).
Both cookie designs have keys to look after. Give each cookie a key ID so that two keys are accepted during a rotation, and never use the signing key to encrypt. Answer three questions about your service, and the table marks which designs fit and why the others do not:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Design | End one session now | Server state | What the holder sees | Cookie size | Keys to look after |
|---|---|---|---|---|---|
| Opaque ID The cookie holds: a random name; the data stays on the server | Good delete the row: the next request is refused | Poor one row per session, in a store every server reaches | Good 32 random characters, nothing to read | Good 32 characters in the example, whatever the data | Good no key; the session store is the secret to protect |
| Signed cookie The cookie holds: the data in clear plus an HMAC tag | Poor valid until it expires; nothing on the server can end it | Good none: the cookie carries the data | Poor the holder reads every field (102 characters) | Mixed 102 characters in the example; grows with the data | Mixed one HMAC key, rotated with a key ID in the cookie |
| Encrypted cookie The cookie holds: the data under AES-GCM | Poor valid until it expires, as the signed cookie | Good none: the cookie carries the data | Good hidden from the holder, and a change is detected | Mixed 95 characters in the example; grows with the data | Mixed one AEAD key, rotated; a nonce must never repeat |
| Signed, with a deny list The cookie holds: a signed cookie plus a list of ended IDs | Mixed works, but every request checks the list | Mixed a list of ended IDs, kept until they would expire | Poor the holder reads every field | Mixed 102 characters in the example, plus an ID to list | Mixed an HMAC key, rotated with a key ID |
How the answers decide
- “Must support or a user be able to end one session at once, for a stolen laptop or a password change?” Yes, within seconds and “May every server look a session up in a shared store or cache on each request?” Yes: rules out Signed cookie and Encrypted cookie and adds a caveat to Signed, with a deny list. A cookie that carries its own data stays valid until it expires. To end one at once you need the server's help, and with a shared store available the opaque ID is the simplest way.
- “Must support or a user be able to end one session at once, for a stolen laptop or a password change?” Yes, within seconds and “May every server look a session up in a shared store or cache on each request?” No, a server must decide alone: rules out Opaque ID, Signed cookie and Encrypted cookie and adds a caveat to Signed, with a deny list. Ending a session at once needs state somewhere. Without a shared store, keep every cookie short-lived and renew it, or accept a deny list that each server can read.
- “Must support or a user be able to end one session at once, for a stolen laptop or a password change?” No, expiry is enough and “May every server look a session up in a shared store or cache on each request?” No, a server must decide alone: rules out Opaque ID. An opaque ID is only a name: it means nothing without the table behind it.
- “Would it matter if the person holding the cookie could read the session data?” Yes, it matters: rules out Signed cookie and Signed, with a deny list. A signed cookie protects against changes, not against reading: the data is Base64url text anyone holding the cookie can decode.
In short, use an opaque ID whenever a shared store is available, which is usually. Use a short-lived self-contained token when each server must decide alone: renew it often against a service that can refuse the renewal, and accept that only expiry, a list or a key change can end it.
Key idea
Whether a session can be ended on the server at once decides the design: a table makes that one deleted row, and a cookie that carries its own data needs extra state to match it.
Checkpoint
Check: Opaque, signed and encrypted sessions
A CSRF token the server recomputes from the session
A page on another site can make a signed-in browser send a request to yours, and the browser adds the session cookie by itself. A CSRF token: A value tied to the signed-in session that a server asks for with each state-changing request, so that a request caused by another site's page, which cannot know the value, is refused. CSRF is cross-site request forgery. makes a request prove that it came from your own page. For software that keeps no token state, OWASP’s CSRF Prevention Cheat Sheet recommends a signed double-submit cookie: an HMAC over the session ID and a random value, so the token is tied to the session it was issued for. Signing a value without that binding, the cheat sheet warns, leaves it open to cookie injection.
The token is the HMAC in hex, a dot and the random value, 64 random bytes as 128 hex characters. The HMAC’s message is
the length of the session ID, a !, the session ID, a !, the length of the random value, a ! and the random value.
The session ID goes into the HMAC but not into the token, because it should never leave the server. The server keeps no
table: it recomputes the HMAC from the session it sees and the random value the token carries. Step through a real
request and two forged ones:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The token is issued. After sign-in the server draws a random value r, computes HMAC(K, 2!S1!128!r) with the lengths written in front, and sends the person r with the HMAC. S1 itself is not in the token.
- The real request. The form sends the token in a field or header, and the browser adds the session cookie. The server recomputes the HMAC from S1 and the token's r and compares it with the token's HMAC.
- Accepted. The two HMACs are equal, compared in constant time, so the request is accepted. Nothing was stored for the token: the server only kept its key.
- A forged request. A page on another site makes the browser send a POST to yours. The browser adds the session cookie by itself, but the page cannot read T1, so the request has no token.
- Refused for no token. The cookie alone proves nothing, because the browser sends it with every request to your site, whoever caused the request. The server finds no valid token and refuses.
- A token from another session. The other site's owner signs in to your service and copies their own token T2, made for session S2. In the forged form it arrives with the cookie S1. The HMAC over S1 and T2's random value differs from T2's.
The program builds the token in Node.js, rejects at once anything that is not two lowercase hex parts of 64 and 128 characters, as the cheat sheet asks, and compares in constant time:
// A CSRF token the server can recompute: HMAC-SHA-256 over the session ID and a random value, with the length of
// each field written in front of it (the construction of the OWASP CSRF Prevention Cheat Sheet).
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
// A repeatable stand-in for the CSPRNG (SHA-256 blocks of a counter); production code uses crypto.randomBytes.
let n = 0;
function demoBytes(length) {
const out = [];
for (let block = 0; out.length < length; block++) out.push(...createHash('sha256').update(`demo ${n}:${block}`).digest());
n++;
return Buffer.from(out.slice(0, length));
}
const secret = demoBytes(32); // the server's CSRF key, never sent anywhere
const framed = (sessionId, random) => `${sessionId.length}!${sessionId}!${random.length}!${random}`;
const unframed = (sessionId, random) => `${sessionId}!${random}`;
const hmacHex = (message) => createHmac('sha256', secret).update(message).digest('hex');
function issue(sessionId) {
const random = demoBytes(64).toString('hex'); // 64 random bytes as 128 hex characters
return `${hmacHex(framed(sessionId, random))}.${random}`; // the session ID itself is not in the token
}
function accepts(sessionId, token) {
if (typeof token !== 'string' || !/^[0-9a-f]{64}\.[0-9a-f]{128}$/.test(token)) return false;
const [given, random] = token.split('.');
return timingSafeEqual(Buffer.from(given, 'hex'), Buffer.from(hmacHex(framed(sessionId, random)), 'hex'));
}
const mine = 'sess_Zm9vYmFy';
const theirs = 'sess_YmF6cXV4';
const token = issue(mine);
const [mac, random] = token.split('.');
const word = (ok) => (ok ? 'accepted' : 'refused');
console.log(`token ${mac.slice(0, 16)}….${random.slice(0, 16)}… (${token.length} characters: 64 for the HMAC, a dot, 128 for the random value)`);
console.log('');
console.log(`own session, own token ${word(accepts(mine, token))}`);
console.log(`another session's token ${word(accepts(mine, issue(theirs)))}`);
console.log(`random value changed ${word(accepts(mine, `${mac}.${'0'.repeat(128)}`))}`);
console.log(`HMAC changed ${word(accepts(mine, `${'0'.repeat(64)}.${random}`))}`);
console.log(`upper-case hex ${word(accepts(mine, token.toUpperCase()))}`);
console.log(`no token ${word(accepts(mine, undefined))}`);
console.log('');
console.log('Why each length goes into the message: two different pairs of (session, random value), one string.');
const [s1, r1, s2, r2] = ['a!b', 'c', 'a', 'b!c'];
console.log(` without lengths "${unframed(s1, r1)}" and "${unframed(s2, r2)}" same HMAC: ${hmacHex(unframed(s1, r1)) === hmacHex(unframed(s2, r2))}`);
console.log(` with lengths "${framed(s1, r1)}" and "${framed(s2, r2)}" same HMAC: ${hmacHex(framed(s1, r1)) === hmacHex(framed(s2, r2))}`); Output
token 53650eb61002d256….6c98a07a4d3ec236… (193 characters: 64 for the HMAC, a dot, 128 for the random value) own session, own token accepted another session's token refused random value changed refused HMAC changed refused upper-case hex refused no token refused Why each length goes into the message: two different pairs of (session, random value), one string. without lengths "a!b!c" and "a!b!c" same HMAC: true with lengths "3!a!b!1!c" and "1!a!3!b!c" same HMAC: false
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node csrf.mjs
The token of another session, a changed random value or HMAC, upper-case hex and a missing token are all refused. The
last two lines show why each length is written into the message. Without lengths the pairs a!b with c, and a with
b!c, make the same string, a!b!c, and so the same HMAC. With lengths they make 3!a!b!1!c and 1!a!3!b!c. Session
IDs rarely hold a !, so this is a design principle more than a bug you will meet. It is the rule of the earlier lesson on
designing authenticated messages: write the message so
that only one set of fields could have produced it.
Two notes close the section. OWASP advises against an expiry time in a CSRF token: it is not an access token, it lives as long as the session, and a new session gets a new token. And a SameSite cookie is defence in depth, not a replacement. It is scoped to a registrable domain and not to an origin, and the Lax setting still sends the cookie on a top-level navigation with a safe method such as GET.
Checkpoint
Check: A CSRF token bound to the session
Practise: issue and redeem a reset link
The lab builds the two halves of a reset flow in Python, with sample tests you can run here. Step one issues a token and stores what the server needs to check it later. Step two redeems it once, in time, and gives the same refusal for every failure:
Code lab · 2 steps
Build a reset link that works once
Issue a selector and verifier that store only a hash, then redeem the link once, in time and in constant time.
- Issue a reset token that the database cannot give away To do
- Redeem a reset token once, in time, without leaks To do
Exercise · Easy · Python
Issue a reset token that the database cannot give away
Write issue_reset_token(rows, user, now, random_bytes) in issue_token.py. rows is a dict that stands in for the database table, now is the time in seconds and random_bytes(n) returns n random bytes (secrets.token_bytes in production, a fixed source in tests). The function returns the token a user receives in an email, and stores what the server needs to check it later:
- Draw the selector first,
random_bytes(12), then the verifier,random_bytes(32). Write each as Base64url without padding (base64.urlsafe_b64encode(...).rstrip(b"=")), which gives 16 and 43 characters, and returnselector + "." + verifier. - Store
rows[selector] = {"user": user, "hash": ..., "expires": now + 600}, wherehashis the SHA-256 of the verifier's text as 64 hex digits. The verifier itself is never stored. - First remove every row of the same
user, so that a newer request ends the older links. Rows of other users stay.
The starter code is the weak version: it stores the token as it is, as the key of the row, and the row has no expiry. Four of the five sample tests fail on it.
Starter code · issue_token.py
def issue_reset_token(rows, user, now, random_bytes):
"""Store what the server needs to check a reset link and return the token that goes into the email."""
token = random_bytes(4).hex() # 32 bits, and the whole token is kept as the key of the row
rows[token] = user
return token The sample tests · test_issue_token.py
import base64
import hashlib
from issue_token import issue_reset_token
class Source:
"""A test source: each call returns the next bytes of a counting sequence, so no two values are alike."""
def __init__(self):
self.next = 1
self.sizes = []
def __call__(self, n):
self.sizes.append(n)
data = bytes((self.next + i * 7) % 256 for i in range(n))
self.next += 1
return data
def b64url(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def test_the_token_is_a_selector_and_a_verifier():
"""12 bytes then 32 bytes, as 16 and 43 characters of Base64url, joined by a dot"""
source = Source()
token = issue_reset_token({}, "asha", 1_000, source)
assert source.sizes == [12, 32]
selector, verifier = token.split(".")
assert (len(selector), len(verifier)) == (16, 43)
assert selector == b64url(bytes((1 + i * 7) % 256 for i in range(12)))
assert verifier == b64url(bytes((2 + i * 7) % 256 for i in range(32)))
def test_the_row_holds_a_hash_and_never_the_verifier():
"""rows[selector] has the user, the SHA-256 of the verifier in hex and an expiry"""
rows = {}
token = issue_reset_token(rows, "asha", 1_000, Source())
selector, verifier = token.split(".")
assert list(rows) == [selector]
assert rows[selector]["user"] == "asha"
assert rows[selector]["hash"] == hashlib.sha256(verifier.encode()).hexdigest()
assert verifier not in repr(rows)
def test_the_link_lives_ten_minutes():
"""expires is now + 600 seconds"""
rows = {}
token = issue_reset_token(rows, "asha", 5_000, Source())
assert rows[token.split(".")[0]]["expires"] == 5_600
def test_a_newer_request_ends_the_older_links_of_that_user_only():
"""two requests by asha leave one row; ravi's row stays"""
rows = {}
source = Source()
first = issue_reset_token(rows, "asha", 1_000, source)
ravi = issue_reset_token(rows, "ravi", 1_001, source)
second = issue_reset_token(rows, "asha", 1_002, source)
assert set(rows) == {second.split(".")[0], ravi.split(".")[0]}
assert first.split(".")[0] not in rows
def test_two_tokens_differ():
"""a source that gives new bytes on every call gives a new token on every call"""
source = Source()
assert issue_reset_token({}, "asha", 1_000, source) != issue_reset_token({}, "asha", 1_000, source) A hint
Build the pieces in order: the two Base64url strings, then hashlib.sha256(verifier.encode()).hexdigest() for the hash, then the dict. Before you write the new row, rebuild rows without the entries whose "user" is the same, for example with a loop over list(rows) that deletes them.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
Redeem a reset token once, in time, without leaks
issue_reset_token from the first step is already in redeem_token.py. Write redeem_reset_token(rows, token, now) beside it. It returns the user the token belongs to, or None:
- Split the token at its first dot into a selector and a verifier. Look the row up by the selector, hash the verifier's text with SHA-256 and compare the two hex digests with
hmac.compare_digest, never with==. - Accept only while
now < row["expires"]: a link made atnowworks atnow + 599and not atnow + 600. - On success, delete the row in the same step, so the link works once, and return the user.
- Every failure returns
Noneand never raises: a missing dot, an empty part, a second dot, an unknown selector, a token that is not a string. A wrong verifier leaves the row where it is, so the owner's real link still works.
The starter code compares the hashes with ==, never deletes a row, never looks at the time and raises on a token that is not a string. Three of the six sample tests fail on it.
Starter code · redeem_token.py
import base64
import hashlib
TTL = 10 * 60
def issue_reset_token(rows, user, now, random_bytes):
"""Given: stores the selector, the hash of the verifier and the expiry, and returns selector.verifier."""
selector = base64.urlsafe_b64encode(random_bytes(12)).rstrip(b"=").decode()
verifier = base64.urlsafe_b64encode(random_bytes(32)).rstrip(b"=").decode()
for old in [s for s, row in rows.items() if row["user"] == user]:
del rows[old]
rows[selector] = {"user": user, "hash": hashlib.sha256(verifier.encode()).hexdigest(), "expires": now + TTL}
return f"{selector}.{verifier}"
def redeem_reset_token(rows, token, now):
"""The user a reset token belongs to, or None. A link works once, for 10 minutes."""
selector, _, verifier = token.partition(".")
row = rows.get(selector)
if row and row["hash"] == hashlib.sha256(verifier.encode()).hexdigest(): # == stops at the first difference
return row["user"] # and the link still works the next time
return None The sample tests · test_redeem_token.py
from redeem_token import issue_reset_token, redeem_reset_token
class Source:
"""A test source: each call returns the next bytes of a counting sequence, so no two values are alike."""
def __init__(self):
self.next = 1
def __call__(self, n):
data = bytes((self.next + i * 7) % 256 for i in range(n))
self.next += 1
return data
def attempt(rows, token, now):
try:
return redeem_reset_token(rows, token, now)
except Exception as e: # noqa: BLE001 - a bad token must never raise
return f"raised {type(e).__name__}"
def test_a_link_works_once():
"""the first use gives the user and removes the row; the second use gives None"""
rows, source = {}, Source()
token = issue_reset_token(rows, "asha", 1_000, source)
assert attempt(rows, token, 1_001) == "asha"
assert rows == {}
assert attempt(rows, token, 1_002) is None
def test_the_link_expires_at_ten_minutes():
"""599 seconds after issue it works, 600 seconds after it does not"""
rows, source = {}, Source()
early = issue_reset_token(rows, "asha", 1_000, source)
late = issue_reset_token(rows, "ravi", 1_000, source)
assert attempt(rows, early, 1_599) == "asha"
assert attempt(rows, late, 1_600) is None
def test_a_wrong_verifier_is_refused_and_does_not_burn_the_link():
"""right selector, wrong verifier: None, and the real link works afterwards"""
rows, source = {}, Source()
token = issue_reset_token(rows, "asha", 1_000, source)
selector, verifier = token.split(".")
forged = selector + "." + ("A" if verifier[0] != "A" else "B") + verifier[1:]
assert attempt(rows, forged, 1_001) is None
assert attempt(rows, selector + ".", 1_001) is None
assert attempt(rows, token, 1_002) == "asha"
def test_the_stored_hash_is_not_a_token():
"""presenting the stored hash as the verifier does not work"""
rows, source = {}, Source()
token = issue_reset_token(rows, "asha", 1_000, source)
selector = token.split(".")[0]
assert attempt(rows, selector + "." + rows[selector]["hash"], 1_001) is None
def test_bad_tokens_give_none_and_never_raise():
"""empty, no dot, empty parts, two dots, an unknown selector, bytes, a number and None"""
rows, source = {}, Source()
token = issue_reset_token(rows, "asha", 1_000, source)
selector, verifier = token.split(".")
for bad in ["", "abc", ".", selector + ".", "." + verifier, token + ".x", "unknown." + verifier, token.encode(), 42, None]:
assert attempt(rows, bad, 1_001) is None, repr(bad)
assert attempt(rows, token, 1_002) == "asha"
def test_other_users_links_are_untouched():
"""redeeming asha's link leaves ravi's row and link as they were"""
rows, source = {}, Source()
asha = issue_reset_token(rows, "asha", 1_000, source)
ravi = issue_reset_token(rows, "ravi", 1_000, source)
assert attempt(rows, asha, 1_001) == "asha"
assert attempt(rows, ravi, 1_002) == "ravi" A hint
Check the shape first: isinstance(token, str) and token.count(".") == 1, both parts not empty. Then fetch the row with rows.get(selector) and return None if it is missing. Test the hash and the time, and only after both pass, del rows[selector] and return row["user"].
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on the six ideas of the lesson. The session asks about your weakest one first and brings back what you miss:
Practice that adapts to you
Practise this lesson
18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid · Warm-up
What does "bearer" mean in "bearer token", and why does it change how you generate, store and log one?
A bearer token works for whoever presents it: the server checks the text of the token, not who sent it. A copy is as good as the original, so a reset link in a log, an API key in a screenshot and a session cookie in a backup each let the finder act as the owner. That changes three habits. Generate the token with at least 128 random bits from a CSPRNG, because guessing is then the only attack that needs no copy. Store a hash of the token, not the token, so that a read of the database is not a set of working tokens; a fast hash such as SHA-256 is enough when the token is random. Keep it out of logs, error pages and web addresses where you can, logging a public selector or the last four characters instead.
-
Mid to senior
A review finds that your password-reset tokens are six random digits, valid for 24 hours, stored as plain text and still usable after a successful reset. Rank the problems, then redesign the flow and say what each change costs a legitimate user.
-
Senior to staff
Your web app keeps sessions in signed cookies that last 30 days, with no session table. A new requirement says that logging out everywhere, or changing the password, must end other sessions within one minute. Compare the options, choose one, and give numbers for what it costs to run.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is a bearer secret, and how many random bits must it carry?
RFC 6750: any party in possession of the token can use it, and the server checks nothing about who sent it, so the only protection against guessing is the random bits behind it. ASVS 5.0 requirement 11.5.1 sets the floor: a CSPRNG and at least 128 bits of entropy for every value meant to be non-guessable. OAuth (RFC 6749 section 10.10) asks for a guessing chance of at most 2^-128, preferably 2^-160. A UUID does not qualify.
-
How long does guessing take for 32, 64 and 128 bits, and why can a six-digit code be safe?
With 100,000 valid tokens and 10,000 guesses a second, a 32-bit token fell in 4.3 seconds and 64 bits take about 585 years (213 days against a guesser a thousand times faster). With 128 bits even a guesser a billion times faster needs about 10^13 years. A six-digit emailed code has 20 bits and is safe only because it lives at most 10 minutes and attempts are limited: five attempts leave a 0.0005 % chance.
-
What should the database keep for a reset token, and why is a fast hash enough?
A selector, the public name of the row, and the SHA-256 of the verifier, the secret. The link carries both; the server looks the row up by selector, hashes the presented verifier and compares the digests in constant time. The selector alone is never accepted. A fast hash is enough because 128 random bits leave no list of likely values to hash; ASVS 6.5.2 lets a secret with at least 112 bits use a standard hash.
-
What makes a password-reset flow safe in time and in state?
The link lives for minutes (an out-of-band code at most 10 minutes) and works once, and a newer request, a used link or a password change ends older links. Serve the reset page with Referrer-Policy: no-referrer, and let the server swap the token for a short-lived session that may only set a password. Build the link from a configured address, never the Host header; answer the same whether or not an address has an account; log the selector, never the verifier.
-
What makes an API key format easy for scanners to recognise?
A prefix that names the kind of token and its environment (demo_live_), then 43 Base62 characters from 32 random bytes (256 bits), then six characters that are the CRC-32 of everything before. The server stores the SHA-256 of the whole key, and the prefix and last four characters to show in a list. The checksum is no security feature: it only makes false alarms cheap to dismiss. RFC 8959 registers the secret-token URI scheme.
-
How do opaque, signed and encrypted session tokens differ?
Opaque: a random ID, with the data on the server. Signed: the data plus an HMAC tag that detects changes; the holder can still read it. Encrypted: the data under an authenticated cipher such as AES-GCM, which hides it and detects changes. Delete the opaque row and the cookie is refused at once; signed and encrypted cookies stay valid until they expire unless you keep a list of ended tokens, a per-user cut-off or a rotated key (ASVS 7.4.1).
-
How is a session-bound CSRF token built and checked?
OWASP's signed double-submit cookie: the token is the HMAC in hex, a dot and a random value (64 bytes as 128 hex characters). The HMAC's message holds the length and value of the session ID and of the random value, with ! between the parts; the session ID goes into the HMAC but never into the token. The server keeps no table: it recomputes the HMAC and compares in constant time. Lengths stop a!b with c and a with b!c from making one string.
-
Does a SameSite cookie replace a CSRF token?
No, it is defence in depth. It is scoped to a registrable domain, not to an origin, and the Lax setting still sends the cookie on a top-level navigation with a safe method such as GET. OWASP also advises against an expiry time in a CSRF token: it lives as long as the session, and a new session gets a new token.
Key takeaways
- A bearer secret works for whoever holds it, so it needs at least 128 random bits from a CSPRNG: a 32-bit token fell in 4.3 seconds, and 64 bits lasted 585 years only against a slow guesser.
- Store the selector and the SHA-256 of the verifier. A fast hash is enough for 128 or more random bits, and a copy of the table then holds no working link.
- A reset link lives for minutes and works once: the fixed store passed all eight checks where the weak one passed two, and a newer request, a used link or a password change ends the older links.
- Give keys a prefix and a checksum so that scanners can find them, and remember that the checksum filters false alarms and proves nothing about whether a key is real.
- A session that must end at once needs a server-side table, because signed and encrypted cookies stay valid until they expire.
- A CSRF token is an HMAC of the session ID and a random value with the length of each field in its message, and the server recomputes it on every request.
Terms in this lesson
- bearer token
- A secret that works for whoever presents it: the server checks the text, not who sends it. Reset links, API keys and session cookies are bearer tokens, so anyone who copies one can use it.
- CSRF token
- A value tied to the signed-in session that a server asks for with each state-changing request, so that a request caused by another site's page, which cannot know the value, is refused. CSRF is cross-site request forgery.
- opaque session token
- A random session ID that means nothing by itself. All the session's data stays on the server, which ends the session by deleting the row.
- secret scanning
- Software that searches repositories, logs and messages for leaked secrets by their shape, such as a known prefix and a valid checksum, so that a leak can be found and the key revoked.
- selector and verifier
- A token in two parts joined by a dot. The selector is public and finds the stored row; the verifier is secret, and only its hash is stored and compared. The selector alone is never enough.
- self-contained token
- A token that carries its own data and protects it with a signature, a MAC or encryption, so a server can accept it without a lookup. The server cannot take it back before it expires.
References
- OWASP Application Security Verification Standard 5.0, V11 Cryptography (requirement 11.5.1) (OWASP Foundation)
- OWASP Application Security Verification Standard 5.0, V6 Authentication (requirements 6.3.8, 6.4.1 and 6.5.1 to 6.6.3) (OWASP Foundation)
- OWASP Application Security Verification Standard 5.0, V7 Session Management (requirements 7.2.3 and 7.4.1) (OWASP Foundation)
- OWASP Session Management Cheat Sheet (OWASP Foundation)
- OWASP Forgot Password Cheat Sheet (OWASP Foundation)
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet (OWASP Foundation)
- RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage (Internet Engineering Task Force (IETF))
- RFC 6749: The OAuth 2.0 Authorization Framework (section 10.10, credentials-guessing attacks) (Internet Engineering Task Force (IETF))
- RFC 8959: The "secret-token" URI Scheme (Internet Engineering Task Force (IETF))
- RFC 9562: Universally Unique IDentifiers (UUIDs), section 8 (Internet Engineering Task Force (IETF))
- Behind GitHub's new authentication token formats (GitHub)
- Node.js 24 documentation: Crypto (randomBytes, createHash and timingSafeEqual) (OpenJS Foundation (Node.js))
- Python 3.14 documentation: secrets, generating secure random numbers for managing secrets (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress