Your country

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

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

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.

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Characters for 128 bits, and the time a guesser needs JavaScript · guess-time-js/guess_time.mjs
// 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

  1. Question 1 of 2 How many Base64url characters does it take to carry at least 128 bits?

    Type a number.

    A hint

    Each Base64url character carries 6 bits.

    Show the answer: Question 1 of 2

    Answer: 22 characters

    128 ÷ 6 = 21.3, so 22 characters are needed, and they carry 22 × 6 = 132 bits. Hex needs 32 characters for 128 bits and Base32 needs 26, as the first table of the program showed.

  2. Question 2 of 2 A service issues 12-character hex reset tokens, which carry 48 bits, and 50,000 of them are pending. A guesser makes 5,000 guesses a second. About how many days until the first hit?

    Type a number.

    A hint

    A guess hits any of the pending tokens, so divide 2⁴⁸ by 50,000 before dividing by the guess rate.

    Show the answer: Question 2 of 2

    Answer: 13 days (anything from 12 to 14 counts)

    2⁴⁸ ÷ 50,000 is about 5.6 × 10⁹ guesses, and at 5,000 a second that is about 1.1 × 10⁶ seconds, which is 13 days. The same 48 bits would last far longer with one pending token, which is why the number of valid tokens belongs in the sum.

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:

Interactive · message by message One reset link, from issue to first use Step through what the server writes down when it issues a link and what it checks when the link comes back.
One reset link, from issue to first use, step 8 of 8. Messages so far: Your server to Database: selector, hash, expiry; Your server to The person: selector.verifier; The person to Your server: selector.verifier; Your server to Database: row for this selector?; Database to Your server: hash, expiry, unused; Your server to Database: delete the row.The personopens the email linkYour serverdrawsDatabaserow goneselector, hash, expiryselector.verifierselector.verifierrow for this selector?hash, expiry, unuseddelete the row

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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 token split into a selector and a verifier JavaScript · selector-js/selector_verifier.mjs
// 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

  1. Question 1 of 2 Which parts of a selector.verifier reset token does the table store?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The selector and the SHA-256 of the verifier

    The row holds the selector, the user, an expiry and the SHA-256 of the verifier. A copy of the table then contains nothing that can be presented as a link.

  2. Question 2 of 2 Passwords need Argon2id, but a 256-bit verifier is stored as a plain SHA-256. Why is that enough?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: There is no short list of likely verifiers to hash, so a thief would have to search about 2²⁵⁶ values

    ASVS requirement 6.5.2 allows a standard hash for a lookup secret of at least 112 bits. A 256-bit verifier is far above that, so only a search of the whole space would recover it from its hash.

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:

Interactive · stage by stage The life of a reset link, and where it ends Step through six stages, from the request to the cleanup. Each stage names the rule that keeps the link from being reused or guessed.
The life of a reset link, and where it ends, step 6 of 6. Showing Request a reset, Issue the link, Email it, Open the link, Set a new password, Clean up. In focus: Clean up.expired or used: ask againRequest a resetsame reply for every addressIssue the linkhash storedEmail itHTTPSOpen the linktoken traded for a sessionSet a new passwordpolicy checkedClean upevery link and session ends

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Eight checks against a weak store and a fixed one Python · reset-py/reset_flow.py
"""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

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

  1. Question 1 of 2 Someone who knows only the second at which a reset link was requested recomputes its 32-bit token. What flaw allows that?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The generator was seeded with the clock, so the token is a function of the second

    random.Random(int(now)) gives the same bits for the same second. The fix draws every token from the operating system's CSPRNG, which has no seed that anyone else can know.

  2. Question 2 of 2 A reset link is issued at second 1,000 and the server accepts it while now < expires with a life of 10 minutes. At which second is it first refused?

    Type a number.

    A hint

    10 minutes are 600 seconds, and the test is strictly less than.

    Show the answer: Question 2 of 2

    Answer: 1600 seconds

    expires = 1,000 + 600 = 1,600. At second 1,599 the test now < 1600 still holds, and at 1,600 it fails. The check "it works at 9:59 and not at 10:00" tests exactly this edge.

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.

A prefixed key with a checksum, its record and a log scanner Python · api-key-py/api_key.py
"""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

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

  1. Question 1 of 2 What does the checksum at the end of an API key prove?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Only that the string was probably typed or copied correctly, which lets a scanner discard look-alikes

    The checksum filters false alarms. Authentication still means looking up the hash of the key in the server's own table.

  2. Question 2 of 2 A scanner finds a string in a log with the prefix and the length of a live key, but its checksum fails. What should it do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Drop it without calling the issuer, because it is almost certainly not a key

    The scanner in the program dropped the look-alike whose last character had changed and reported only the key whose checksum passed.

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:

One session in three cookies JavaScript · cookies-js/session_cookies.mjs
// 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:

Interactive · answer and compare Which session token fits your service? Answer three questions about your service. The table marks which designs fit and says why the others do not.
Must support or a user be able to end one session at once, for a stolen laptop or a password change?
May every server look a session up in a shared store or cache on each request?
Would it matter if the person holding the cookie could read the session data?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four session designs against revocation, state, what the holder sees, size and keys
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

  1. Question 1 of 2 Support must be able to end one stolen session within a second. Which design does that with the least extra machinery?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An opaque ID with a server-side table: delete the row

    A cookie that carries its own data needs a deny list, a per-user cut-off time or a new signing key to be ended early (ASVS 7.4.1). The opaque ID needs only the delete it already has.

  2. Question 2 of 2 Which of these cookies could the person holding it read the data from?
    Show the answer: Question 2 of 2

    Answer:

    • The signed cookie
    • The signed cookie with a deny list

    Signing protects against changes and encryption protects against reading. If the data must stay hidden from the holder, use an encrypted cookie or keep the data on the server.

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:

Interactive · message by message One CSRF token, one real request and two forged ones Step through a signed-in browser, your server and a page you do not control. The server recomputes the HMAC from the session it sees.
One CSRF token, one real request and two forged ones, step 6 of 6. Messages so far: Your server to Signed-in browser: page with token T1; Signed-in browser to Your server: POST, cookie S1, T1; Other site to Signed-in browser: auto-submitting form; Signed-in browser to Your server: POST, cookie S1, no token; Signed-in browser to Your server: POST, cookie S1, T2.Signed-in browsercookie: session S1Your serverkey K, sessionsOther sitea page you do not controlpage with token T1POST, cookie S1, T1auto-submitting formPOST, cookie S1, no tokenPOST, cookie S1, T2refused: bound to S2

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 session-bound CSRF token and the checks it meets JavaScript · csrf-js/csrf.mjs
// 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

  1. Question 1 of 2 Why does the CSRF token's HMAC cover the session ID although the token does not contain it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It binds the token to one session, so a token issued for another session fails the check

    Signing a value without binding it to the session would leave the token open to cookie injection. In the program the token of another session was refused.

  2. Question 2 of 2 What do the lengths written in front of the session ID and the random value in the HMAC message prevent?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Two different pairs of fields producing the same message string, and so the same HMAC

    An unambiguous message is the rule from the lesson on authenticated messages: only one set of fields should be able to produce a given string.

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.

  1. Issue a reset token that the database cannot give away To do
  2. 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 return selector + "." + verifier.
  • Store rows[selector] = {"user": user, "hash": ..., "expires": now + 600}, where hash is 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.

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 at now works at now + 599 and not at now + 600.
  • On success, delete the row in the same step, so the link works once, and return the user.
  • Every failure returns None and 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"].

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.

  1. Question 1 of 18 How many hex digits carry exactly 128 bits?

    Type a number.

    A hint

    A hex digit carries 4 bits.

    Show the answer: Question 1 of 18

    Answer: 32 hex digits

    128 ÷ 4 = 32 hex digits, which is 16 bytes. The same 128 bits take 26 Base32 characters or 22 Base64url characters.

  2. Question 2 of 18 A teammate proposes uuid4() as an API key because it is random. What is the right answer?

    Choose one answer.

    Show the answer: Question 2 of 18

    Answer: Use 32 bytes from a CSPRNG instead, because ASVS 11.5.1 says UUIDs do not meet the 128-bit requirement and RFC 9562 says not to use them as security capabilities

    Generate 32 random bytes with the platform's CSPRNG and write them in Base62, hex or Base64url. Use a UUID where you need an identifier, not a secret.

  3. Question 3 of 18 A product manager wants a six-digit code in the reset email, "so users can type it". What makes that acceptable?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: Attempts per code are limited to a few, the code lives at most 10 minutes, and the response does not reveal whether the address exists

    The safety of a short code comes from the limit on attempts and its short life, not from its bits. A leaked table of hashes of six-digit codes is open at once, so such codes also need salted slow hashing if they are stored.

  4. Question 4 of 18 Which function stores a random 256-bit reset verifier?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: SHA-256 of the verifier

    Passwords come from a short list and need a slow hash. A random verifier has no list, so a fast hash does the job.

  5. Question 5 of 18 Why is it fine for the selector to appear in access logs?

    Choose one answer.

    Show the answer: Question 5 of 18

    Answer: It is only a name for the row. It has no relation to the verifier, which is drawn separately, and the selector alone is never accepted

    The verifier is the secret and must stay out of logs and queries. The selector may be logged, indexed and shown.

  6. Question 6 of 18 A copy of the reset table leaks, with selectors, hashes, users and expiries. What can the holder of the copy do?

    Choose every answer that is right.

    Show the answer: Question 6 of 18

    Answer:

    • Find out which accounts have a pending reset

    The hash keeps working links out of a read-only leak. It does not make the table harmless: the rows still say who asked for a reset.

  7. Question 7 of 18 Asha requests a reset twice, a minute apart. What should happen to the first link?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: It stops working as soon as the second is issued

    The fixed store deleted the account's older rows when it issued a new link. The check "a newer request ends the older link" failed on the weak store.

  8. Question 8 of 18 Why must the reply to a reset request look and time the same for an address with no account?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: Otherwise the form tells anyone which addresses have an account

    Answer with the same message in the same time, limit requests per account and tell the owner by email whether a reset was asked for.

  9. Question 9 of 18 A reset link is opened. Where does the token leak, and what is the fix?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: It sits in the address, so the Referer header, logs and history can carry it. Send Referrer-Policy no-referrer and trade it at once for a short session at an address without the token

    Referrer-Policy keeps the address out of the Referer header of requests the reset page makes, and the redirect keeps the token out of the address bar, the history and later logs.

  10. Question 10 of 18 What is the prefix demo_live_ at the start of an API key for?

    Choose one answer.

    Show the answer: Question 10 of 18

    Answer: It says what the string is and in which environment, so scanners can find it and people know whose key it is

    The prefix, the 256 random bits and the checksum have different jobs: recognising the string, resisting guessing and discarding look-alikes.

  11. Question 11 of 18 A request arrives with a key whose checksum is valid. What does the server do?

    Choose one answer.

    Show the answer: Question 11 of 18

    Answer: It looks the SHA-256 of the key up in its table, exactly as it would for a key with any other checksum

    The server may check the checksum first to save a database read for typing mistakes, but the lookup of the hash is the check that matters.

  12. Question 12 of 18 A scanner reports a string with a valid checksum in a public repository. What is the right sequence?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: Revoke the key, issue a new one, then look through the server's logs for use of the old one

    The checksum makes the alarm trustworthy, and a trustworthy alarm deserves immediate revocation.

  13. Question 13 of 18 What does a signed cookie protect against that an unsigned one does not?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: Changes to its content

    Signing gives integrity and encryption gives confidentiality as well. Neither gives revocation without help from the server.

  14. Question 14 of 18 Your sessions are signed cookies valid for 30 days. A user changes their password and expects all other devices to be signed out. What works?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: A per-user cut-off time: refuse any cookie issued before the password change

    A cut-off time per user, a list of ended tokens or a new signing key all add state or break all sessions. The opaque ID already has the state.

  15. Question 15 of 18 A service at the edge cannot reach any shared store, but support must still be able to end a session within about a minute. What is the honest design?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: Self-contained tokens that live about a minute, renewed against a service that can refuse the renewal

    Ending a session within a minute needs a place that can say no. A short lifetime makes that place a renewal, not a check on every request.

  16. Question 16 of 18 A request arrives with the cookie of session S1 and a CSRF token that was issued for session S2. What does the server do?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: It refuses the request, because the HMAC over S1 differs from the token's

    The program refused "another session's token". Without the binding, a token copied from the attacker's own session would work in the victim's.

  17. Question 17 of 18 Why does OWASP recommend an HMAC bound to the session over a random value that is simply sent in a cookie and in a field?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: Someone who can set a cookie on your site, for example from a sibling subdomain, could plant their own matching pair; the HMAC cannot be made without the key

    The naive pattern only compares two values that the attacker may control. The signed pattern needs a value that only the server's key can produce for the current session.

  18. Question 18 of 18 A colleague wants to add a timestamp to the CSRF token so that it expires after 10 minutes. What do you say?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: Leave it out. A CSRF token is not an access token, it is used to verify requests throughout the session, and a new session gets a new token

    The token proves that a request comes from a page the server rendered for this session. Its lifetime is the session's, and SameSite cookies and Fetch Metadata checks can add defence in depth.

Interview questions

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

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

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

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

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

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

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

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

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

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

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

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

  6. 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).

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

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

Related tools

Report a problem with this lesson

Quick answers and tool search

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