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.

Web Security (OWASP Top 10:2025)  Module 3 – A02:2025 Security Misconfiguration

Caching safely: cache keys, Vary and deception

Classify responses before anything caches them, key every input that changes them, and stop cache poisoning and web cache deception across the whole path.

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

What you will learn

  • Classify each response as public, private or never stored, and give it the Cache-Control that says so
  • Explain how a cache key decides a hit, and why an input outside the key must never change a stored response
  • Use Vary for the request headers a response depends on, without wrecking the hit ratio
  • Prevent web cache deception with strict routes, origin headers the edge obeys and content-type checks
  • Test every layer of the cache path, application caches included, with two users and one canary

Before you start

What you’ll learn · Before you start

What you will learn

  • Classify each response as public, private or never stored, and give it the Cache-Control that says so
  • Explain how a cache key decides a hit, and why an input outside the key must never change a stored response
  • Use Vary for the request headers a response depends on, without wrecking the hit ratio
  • Prevent web cache deception with strict routes, origin headers the edge obeys and content-type checks
  • Test every layer of the cache path, application caches included, with two users and one canary

Before you start

On this page

A shared cache keeps one copy of a response and hands it to everyone whose request has the same key, and the key is usually just the method and the URL. In this lesson’s model of the HTTP caching rules, an account page sent with nothing more than Cache-Control: max-age=60 was stored once and served, 30 seconds later, to the next user who asked.

You will classify each response before anything caches it, decide what goes into the key with Vary, find inputs that change a response without being in its key, stop personal pages being stored under addresses that look static, and test every layer from the origin to the browser.

A shared cache answers from its key, not from who is asking

A browser keeps a private cache for its one user. A CDN edge or a reverse proxy in front of your app is a shared cache: A cache whose stored responses can answer many users, such as a CDN edge or a reverse proxy. A private cache, usually the browser's, belongs to one user. RFC 9111 forbids a shared cache to store a response marked private.: its stored responses can answer anyone. RFC 9111 builds a cache’s cache key: The information a cache uses to find a stored response for a request: at least the method and the URL, plus any request headers the response names in Vary. Two requests with the same key get the same stored answer. from the request method and the target URI at a minimum, and since many caches store only GET responses, for most stored responses the key is the URL. nginx keys on $scheme$proxy_host$request_uri unless you configure something else. Follow two users through one cache:

Interactive · message by message Two users, one cache key Step through Ana's request, then Ben's, and watch which parts of a request the cache's key holds.
Two users, one cache key, step 7 of 7. Messages so far: Browsers to Shared cache: GET /account, Ana's cookie; Shared cache to Origin: GET /account; Origin to Shared cache: 200, max-age=60; Shared cache to Browsers: Ana's page; Browsers to Shared cache: GET /account, Ben's cookie; Shared cache to Browsers: Ana's page, from the cache.BrowsersAna, then BenShared cachenothing storedOriginthe shop's appGET /account, Ana's cookieGET /account200, max-age=60Ana's pageGET /account, Ben's cookieAna's page, from the cache

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

Every step as text
  1. Ana asks. Ana opens her account page. Her request carries her session cookie, but the cache builds its key from the method and the URL alone, GET https://shop.example/account.
  2. A miss. Nothing is stored under that key yet, so the cache forwards the request. The origin reads the cookie and builds Ana's page, with her name, address and orders.
  3. A policy for everyone. The origin answers with Cache-Control max-age=60 and nothing that says the page belongs to one person. A shared cache may store such a response, and this one does.
  4. Ana's page. Ana receives her own page. So far everything looks right, which is why this mistake survives testing with one account.
  5. Ben asks. Thirty seconds later Ben opens the same URL with his own cookie. The cookie is not part of the key, so his request has exactly the key of Ana's.
  6. A hit. The stored copy is still fresh, so the cache answers at once with Ana's page. The origin never sees Ben's request and has no chance to check his session.
  7. The fix. With Cache-Control private or no-store on the account page, the shared cache keeps nothing, every request reaches the origin, and each user gets a page of their own.

Nothing in the key said who asked. The session cookie travelled with both requests, but RFC 9111 points out that Set-Cookie does not stop a response from being cached, and MDN’s caching guide is blunt about it: a cookie alone does not make a response private. An Authorization header is the exception, because RFC 9111 section 3.5 keeps a shared cache from reusing such a response unless the response allows it, and the next section shows how easily a response does. One proxy can hide the bug: nginx stores no response that carries Set-Cookie unless proxy_ignore_headers Set-Cookie switches that off, while another cache in the path may follow the standard instead. A default that happens to protect you on one layer is not a policy. A shared cache is only safe when the origin tells it, response by response, what it may keep.

Key idea

A shared cache answers from its key, the method and the URL, and the key says nothing about the user: only the origin’s Cache-Control decides whether a personal page may be kept.

Classify every response before anything caches it

Each directive of Cache-Control gives caches a precise permission. These are the ones that decide safety, as RFC 9111 defines them:

Directive Shared caches The browser
public may store, even a response to a request with Authorization may store
max-age=N may reuse it for N seconds the same
s-maxage=N replaces max-age here, and allows storing for Authorization ignores it
private must not store it may store
no-cache may store, but must revalidate before every reuse the same
no-store must not store any part of it the same

This program applies those rules to seven ways of answering Ana’s request for her account page, and asks what Ben receives 30 seconds later:

Seven policies for one account page, and the next user JavaScript · storage/storage_rules.mjs
// A shared cache (a CDN or a reverse proxy) reduced to the rules of RFC 9111 that decide two things: may it store a
// response (sections 3 and 3.5), and may a stored response answer the next request (sections 4 and 4.2.2). Ana loads
// her account page, then Ben asks for the same URL 30 seconds later. Names and values are made up.

const DAY = 86400;

/** Cache-Control as a map: 'private, max-age=60' -> { private: true, 'max-age': 60 } */
function directives(header = '') {
  const out = {};
  for (const part of header.split(',')) {
    const [name, value] = part.trim().toLowerCase().split('=');
    if (name) out[name] = value === undefined ? true : Number(value);
  }
  return out;
}

/** May a shared cache store this response? Returns [yes or no, how long it stays fresh, why]. */
function mayStore(request, response) {
  const cc = directives(response.headers['cache-control']);
  if (cc['no-store']) return [false, 0, 'no-store'];
  if (cc.private) return [false, 0, 'private: only a browser may keep it'];
  const allowsShared = cc.public || cc['s-maxage'] !== undefined || cc['must-revalidate'];
  if (request.headers.authorization && !allowsShared) return [false, 0, 'Authorization, and nothing allows sharing'];
  if (cc['no-cache']) return [true, 0, 'no-cache: kept, checked before reuse'];
  const explicit = cc['s-maxage'] ?? cc['max-age'];
  if (explicit !== undefined) return [true, explicit, `fresh for ${explicit} s`];
  // No explicit lifetime: a 200 may get a heuristic one, typically 10 % of the time since Last-Modified.
  if (response.status === 200 && response.lastModifiedAgo !== undefined) {
    const fresh = Math.round(response.lastModifiedAgo / 10);
    return [true, fresh, `heuristic: fresh for ${fresh} s`];
  }
  return [false, 0, 'nothing makes it storable'];
}

/** The origin: each user gets a page of their own. */
function origin(request, cacheControl) {
  const headers = { 'content-type': 'text/html', 'set-cookie': 'sid=new-value; Secure; HttpOnly' };
  if (cacheControl !== null) headers['cache-control'] = cacheControl;
  // Without Cache-Control, the page still carries Last-Modified: here, one day before the response's Date.
  return { status: 200, headers, lastModifiedAgo: cacheControl === null ? DAY : undefined, body: `account page of ${request.user}` };
}

function run(label, cacheControl, withAuthorization) {
  const store = new Map();
  const ask = (user, at) => {
    const request = { url: '/account', user, headers: withAuthorization ? { authorization: `Bearer token-of-${user}` } : {} };
    const key = `GET https://shop.example${request.url}`; // the key: method and URI, nothing about the user
    const kept = store.get(key);
    if (kept && at - kept.at <= kept.fresh) return { from: 'from the cache', body: kept.body };
    const response = origin(request, cacheControl);
    const [keep, fresh, why] = mayStore(request, response);
    if (keep) store.set(key, { body: response.body, fresh, at });
    return { from: kept ? 'after revalidation' : 'from the origin', body: response.body, kept: keep, why };
  };
  const first = ask('Ana', 0);
  const second = ask('Ben', 30);
  const own = second.body === 'account page of Ben';
  const gets = `${own ? 'his own page' : "Ana's page"}, ${second.from}`;
  console.log(`${label.padEnd(34)} ${(first.kept ? 'yes' : 'no').padEnd(5)} ${gets.padEnd(33)} ${own ? 'ok' : 'LEAK'}`);
  console.log(`${''.padEnd(34)} ${first.why}`);
}

console.log(`${'Response to Ana'.padEnd(34)} ${'Kept'.padEnd(5)} Ben, 30 s later, gets`);
run('max-age=60, with Set-Cookie', 'max-age=60', false);
run('no Cache-Control, edited 1 day ago', null, false);
run('private, max-age=60', 'private, max-age=60', false);
run('no-cache', 'no-cache', false);
run('no-store', 'no-store', false);
run('Authorization; max-age=60', 'max-age=60', true);
run('Authorization; public, max-age=60', 'public, max-age=60', true);

Output

Response to Ana                    Kept  Ben, 30 s later, gets
max-age=60, with Set-Cookie        yes   Ana's page, from the cache        LEAK
                                   fresh for 60 s
no Cache-Control, edited 1 day ago yes   Ana's page, from the cache        LEAK
                                   heuristic: fresh for 8640 s
private, max-age=60                no    his own page, from the origin     ok
                                   private: only a browser may keep it
no-cache                           yes   his own page, after revalidation  ok
                                   no-cache: kept, checked before reuse
no-store                           no    his own page, from the origin     ok
                                   no-store
Authorization; max-age=60          no    his own page, from the origin     ok
                                   Authorization, and nothing allows sharing
Authorization; public, max-age=60  yes   Ana's page, from the cache        LEAK
                                   fresh for 60 s

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node storage_rules.mjs

Three of the seven leaked. max-age=60 with a Set-Cookie was enough. So was sending no Cache-Control at all, because a 200 with a Last-Modified header may get heuristic freshness, typically a tenth of the time since that date: 8,640 seconds for a page last changed a day earlier. And public on a response to a request with Authorization undid the one rule that protects such requests, RFC 9111 section 3.5: without public, s-maxage or must-revalidate, a shared cache must not reuse it. private and no-store kept the shared cache out, and no-cache stored the page but asked the origin again, so Ben got his own.

Reading no-cache as do not cache

Cache-Control: no-cache on an account page still lets every cache keep a copy; it only forces a check with the origin before reuse, and MDN notes that a page restored by Back may skip even that. Send no-store for anything that must never be written down, and private, no-cache for personal pages that may stay on the user’s device.

The browser needs a policy too. ASVS 5.0.0 asks for no-store on sensitive data (requirement 14.3.2) and for authenticated data to be cleared from the browser when the session ends, where Clear-Site-Data can help (14.3.1). Two limits apply: MDN describes Clear-Site-Data: "cache" as removing locally cached data, so it never reaches a shared cache, and no-store deletes nothing that a cache already holds, so a policy change needs a purge of the shared caches as well. Decide the class for a response of your own:

Interactive · answer and compare Which Cache-Control fits this response? Answer three questions about one response of your own; the table rules out the classes that would be wrong for it.
Is the response exactly the same for every user who asks for this URL?
Would a copy left in a browser on a shared computer harm the user, as account, payment or health data would?
Does the URL change whenever the content changes, for example through a hash in the file name?

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

Four classes of response, by what they let shared caches and the browser keep
Class of response Shared caches Browser cache A change reaches users If it is personal
Public, versioned file Cache-Control: public, max-age=31536000, immutable Good stored and reused for a year: the cheapest response you serve Good kept for a year, never even revalidated Mixed only through a new URL: the file name must change with the content Poor one copy for everyone, for a year
Public, changes often Cache-Control: public, max-age=300 Good stored and shared for five minutes Good kept for five minutes Good within five minutes Poor in the model, max-age=60 alone gave Ben Ana's page
Private to one user Cache-Control: private, no-cache Good not stored: RFC 9111 forbids a shared cache to keep it Mixed kept, but checked with the origin before each reuse Good at once, through the revalidation Mixed safe from other users, but a copy stays on the device
Never stored Cache-Control: no-store Good not stored anywhere Good not stored, so nothing is left after sign-out Good at once: every request reaches the origin Good nothing kept, nothing to leak; the cost is no reuse at all
How the answers decide
  • “Is the response exactly the same for every user who asks for this URL?” No, it depends on who asks: rules out Public, versioned file and Public, changes often. A shared cache answers everyone from one stored copy: in the model, Ben received Ana's page from max-age=60 alone.
  • “Would a copy left in a browser on a shared computer harm the user, as account, payment or health data would?” Yes, it must not be left behind: rules out Public, versioned file, Public, changes often and Private to one user. Only no-store keeps the browser from writing it down too (ASVS 5.0.0 requirement 14.3.2).
  • “Does the URL change whenever the content changes, for example through a hash in the file name?” No, the URL stays the same: rules out Public, versioned file. With immutable and a year of max-age, users keep the old content until the URL changes, and it never does.
  • “Is the response exactly the same for every user who asks for this URL?” Yes, the same for everyone and “Would a copy left in a browser on a shared computer harm the user, as account, payment or health data would?” No: adds a caveat to Never stored. Correct but costly: every request reaches the origin, and shared content earns nothing from the caches.

In short: public with a year of max-age and immutable only for versioned files, public and short for shared pages that change, private, no-cache for a user’s own pages that are not sensitive, and no-store for everything sensitive and for every response you are unsure about.

Checkpoint

Check: What a shared cache's key holds · Classifying responses with Cache-Control

  1. Question 1 of 3 Ana and Ben each send GET https://shop.example/account with their own session cookie. The response has Cache-Control max-age=60 and no Vary. Which part of Ben's request does the shared cache use to find a stored copy?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: The method and the URL, the same as Ana's, so Ben gets her stored page

    The key holds the method and the URL, so two users asking for the same URL share whatever is stored under it. Only the response's Cache-Control can keep a personal page out of a shared cache.

  2. Question 2 of 3 An API answers requests that carry an Authorization header with Cache-Control public, max-age=60. What may a shared cache do?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: Store the answer and give it to other callers of the same URL for 60 seconds

    A shared cache must not reuse a response to a request with Authorization unless the response explicitly allows it. public is such permission, so it belongs only on answers that are the same for every caller.

  3. Question 3 of 3 Which directive keeps every cache, the browser's included, from storing any part of a response?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: no-store

    no-store is the only directive that forbids storage everywhere. Use it for sensitive responses; use private, no-cache for personal pages that may stay on the user's own device.

Vary puts the headers that change a response into its key

One URL can have several right answers. Take a stock API, /api/stock: it answers in English or Hindi by Accept-Language, compresses by Accept-Encoding, and names the calling partner’s site, read from the request’s Origin, in Access-Control-Allow-Origin. The Vary response header lists the request headers that changed the answer, and RFC 9111 section 4.1 adds them to the key: a stored response is reused only for a request that sends the same values. Vary: * matches no request at all, so such a response is never reused without revalidation. This program sends 1,200 requests through a cache keyed four ways:

1,200 requests, four ways to key one API Python · vary/vary_keys.py
# One API answer behind a shared cache, 1,200 requests, four ways of keying it. The origin's answer depends on three
# request headers: Accept-Language (English or Hindi), Accept-Encoding (gzip or not) and Origin (it names the
# calling site in Access-Control-Allow-Origin). User-Agent changes nothing. Requests are drawn from a fixed seed, so
# every run prints the same numbers.
import random

rng = random.Random(7)
LANGUAGES = ["en", "en-GB,en;q=0.9", "en-US,en;q=0.8", "en-IN,en;q=0.9,hi;q=0.6", "hi", "hi-IN,hi;q=0.9,en;q=0.5"]
ENCODINGS = ["gzip, deflate, br", "identity"]
ORIGINS = ["https://partner-a.example", "https://partner-b.example"]
AGENTS = [f"Browser/{major}.0 (build {build})" for major in range(120, 130) for build in range(4)]  # 40 strings


def answer(headers):
    """What the origin sends for these request headers: the parts of the answer that depend on them."""
    language = "hi" if headers["accept-language"].startswith("hi") else "en"
    encoding = "gzip" if "gzip" in headers["accept-encoding"] else "identity"
    return (language, encoding, headers["origin"])


def normalised(name, value):
    """What an edge rule can reduce a header to before it becomes part of the key."""
    if name == "accept-language":
        return "hi" if value.startswith("hi") else "en"
    if name == "accept-encoding":
        return "gzip" if "gzip" in value else "identity"
    return value


def run(label, vary, normalise=False):
    store = {}
    hits = wrong = 0
    for headers in REQUESTS:
        parts = [normalised(h, headers[h]) if normalise else headers[h] for h in vary]
        key = ("GET", "/api/stock", *parts)
        if key in store:
            hits += 1
            if store[key] != answer(headers):
                wrong += 1
        else:
            store[key] = answer(headers)
    print(f"{label:44} {len(store):5} {hits / len(REQUESTS):8.1%} {wrong:6}")


REQUESTS = [
    {
        "accept-language": rng.choice(LANGUAGES),
        "accept-encoding": rng.choice(ENCODINGS),
        "origin": rng.choice(ORIGINS),
        "user-agent": rng.choice(AGENTS),
    }
    for _ in range(1200)
]

print(f"{'Cache key: method and URL, plus':44} {'keys':>5} {'hit ratio':>8} {'wrong':>6}")
run("nothing (no Vary)", [])
run("Vary: Accept-Language, Accept-Encoding, Origin", ["accept-language", "accept-encoding", "origin"])
run("the same three, normalised at the edge", ["accept-language", "accept-encoding", "origin"], normalise=True)
run("the same three, plus Vary: User-Agent", ["accept-language", "accept-encoding", "origin", "user-agent"], normalise=True)

Output

Cache key: method and URL, plus               keys hit ratio  wrong
nothing (no Vary)                                1    99.9%   1002
Vary: Accept-Language, Accept-Encoding, Origin    24    98.0%      0
the same three, normalised at the edge           8    99.3%      0
the same three, plus Vary: User-Agent          309    74.2%      0

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

Without Vary the cache kept one copy and gave the wrong language, encoding or partner origin to 1,002 requests. Listing the three headers made every answer right at a 98.0 % hit ratio, and normalising them at the edge to the values the origin actually uses, en or hi and gzip or not, cut 24 keys to 8 and raised the ratio to 99.3 %. Adding User-Agent, which changes nothing in this answer, took the keys to 309 and the hit ratio down to 74.2 %; MDN advises against varying on it for exactly that reason. Vary on the headers the response depends on, no more, and normalise them the same way on every hop.

Vary: Cookie as an access rule

Adding Vary: Cookie to personal pages looks like a fix, but OWASP’s cache guide warns against treating it as an authorisation boundary: every user’s copy still sits in a shared store, and one changed analytics cookie splits the key. Personal pages get private or no-store.

Checkpoint

Check: Vary and the cache key

  1. Question 1 of 2 An API returns Access-Control-Allow-Origin with the calling partner's origin, and a shared cache stores it for 60 seconds. Partner B's calls start failing right after partner A's. What is missing?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Vary Origin, so that each partner's answer is stored under its own key

    A response that depends on a request header must list it in Vary, or the cache serves one caller's variant to another. Here the browser of partner B sees A's origin and refuses the response.

  2. Question 2 of 2 In the program's run, what hit ratio did the cache keep once Vary also named User-Agent, which the answer does not depend on? Give the percentage.

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 74.2 % (anything from 74.1 to 74.3 counts)

    74.2 %, down from 99.3 % with the three normalised headers, because 40 user-agent strings split every key. Vary on exactly the headers the response depends on.

An input outside the key must never change a stored response

Vary covers the headers you meant to depend on. The dangerous ones are those you depend on by accident: an unkeyed input: A part of a request that changes the response but is not part of the cache key, such as a forwarded header or a cookie. Whatever it did to the stored copy is then served to everyone who asks with the same key. that changes the response while the key ignores it. A common one is X-Forwarded-Host, which many frameworks use to build absolute URLs once they are told to trust a proxy, and which a cache that keys on host, path and query never reads. One request with an odd value can then store a page whose script comes from another host, and everyone with the same key receives it. RFC 9111 section 7.1 calls this cache poisoning: Getting a harmful response stored in a shared cache, so that the cache serves it to other users. It usually works through an unkeyed input that the origin reflects into a response it marks cacheable.: a harmful response stored once and served to many users.

The check below sends a canary, a unique made-up value, in that header and fails if a stored copy contains it. canary.invalid uses a top-level domain that RFC 2606 reserves, so no such host can exist:

A canary in X-Forwarded-Host, against two origins and an edge rule JavaScript · poison/unkeyed_input.mjs
// A regression check for one unkeyed input. The shared cache keys on the host, the path and the query, as most caches
// do by default; X-Forwarded-Host is not in the key. The check sends one request with a canary value in that header,
// then asks for the page as a normal visitor would, and fails if the stored copy contains the canary. canary.invalid
// is a reserved name (RFC 2606): no such host can exist. Run checks like this only against caches you own.

const PUBLIC_ORIGIN = 'https://shop.example'; // configuration, set once per environment

const weakOrigin = (req) => {
  const host = req.headers['x-forwarded-host'] ?? req.headers.host; // the request decides the script's host
  return `<script src="https://${host}/static/app.js"></script>`;
};
const fixedOrigin = () => `<script src="${PUBLIC_ORIGIN}/static/app.js"></script>`;

function makeCache(origin, edgeDropsForwarded) {
  const store = new Map();
  return (req) => {
    if (edgeDropsForwarded) {
      const { 'x-forwarded-host': _dropped, ...kept } = req.headers;
      req = { ...req, headers: kept };
    }
    const key = `${req.headers.host}${req.path}`; // host, path and query: the header is not in it
    if (!store.has(key)) store.set(key, origin(req)); // the page is public: max-age=600
    return store.get(key);
  };
}

function check(label, origin, edgeDropsForwarded, path) {
  const cache = makeCache(origin, edgeDropsForwarded);
  cache({ path, headers: { host: 'shop.example', 'x-forwarded-host': 'canary.invalid' } }); // the test request
  const testCopy = cache({ path, headers: { host: 'shop.example' } }); // a visitor to the same URL
  const homePage = cache({ path: '/', headers: { host: 'shop.example' } }); // a visitor to the home page
  const failed = testCopy.includes('canary.invalid');
  const host = (page) => page.match(/src="https:\/\/([^/]+)/)[1];
  const others = path === '/' ? '' : `; / from ${host(homePage)}`;
  console.log(`${label.padEnd(36)} ${failed ? 'FAIL' : 'pass'}  script of ${path} from ${host(testCopy)}${others}`);
}

console.log('Check: a canary in X-Forwarded-Host must never reach a stored copy');
check('weak origin, tested on /', weakOrigin, false, '/');
check('weak origin, tested on /?check=7f3a', weakOrigin, false, '/?check=7f3a');
check('fixed origin, PUBLIC_ORIGIN', fixedOrigin, false, '/?check=7f3a');
check('weak origin, edge drops the header', weakOrigin, true, '/?check=7f3a');

Output

Check: a canary in X-Forwarded-Host must never reach a stored copy
weak origin, tested on /             FAIL  script of / from canary.invalid
weak origin, tested on /?check=7f3a  FAIL  script of /?check=7f3a from canary.invalid; / from shop.example
fixed origin, PUBLIC_ORIGIN          pass  script of /?check=7f3a from shop.example; / from shop.example
weak origin, edge drops the header   pass  script of /?check=7f3a from shop.example; / from shop.example

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node unkeyed_input.mjs

Against the weak origin the check failed both times. Tested on /, the canary went into the copy that every visitor gets, which is why such checks run against caches you own and on a test-only query string: on /?check=7f3a the same weakness showed, while / stayed clean, because the query is part of the key. Two fixes passed: an origin that builds every absolute URL from configuration, PUBLIC_ORIGIN, and an edge that drops a client’s X-Forwarded-Host before passing the request on.

Read only the keyed header

Press Edit and, in weakOrigin, change req.headers['x-forwarded-host'] ?? req.headers.host to req.headers.host. Run it: every check passes, because the origin now depends only on a header that is part of the key.

OWASP’s cache guide completes the list: trust forwarded headers only when your own proxy wrote them, never echo unkeyed request data into cacheable output, and never let the body of a GET change a cacheable response.

Key idea

Everything that can change a cacheable response belongs in its key or is ignored: build URLs from configuration, and prove it with a canary on a query string that only your test uses.

A static-looking address must never store a personal page

Caches are often told to keep anything that looks like a file: names ending in .css, .js or an image type. That rule is safe only if no such address can ever return a personal page, and many routers break the promise: a route for /account/profile may also answer /account/profile/avatar.css, ignoring the extra segment. The result is web cache deception: A shared cache storing a personal page under an address that looks like a static file, such as /account/x.css, because the router answered the longer path and a cache rule keyed on the extension.. A signed-in user follows a link to such an address, their page is stored under it, and anyone who asks for the same address receives it. Step through it:

Interactive · message by message A personal page stored under a stylesheet's address Step through one link that a signed-in user follows, the copy the edge keeps, the next visitor, then each fix.
A personal page stored under a stylesheet's address, step 9 of 9. Messages so far: Browsers to Edge cache: GET …/avatar.css, cookie; Edge cache to Origin: GET …/avatar.css; Origin to Edge cache: 200 text/html, no-store; Browsers to Edge cache: GET …/avatar.css, no cookie; Edge cache to Browsers: Ana's profile, from cache.BrowsersAna, then a visitorEdge cachenothing storedOrigin404 for unknown pathsGET …/avatar.css, cookieGET …/avatar.css200 text/html, no-storeGET …/avatar.css, no cookieAna's profile, from cache

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

Every step as text
  1. A link. Ana is signed in and follows a link to /account/profile/avatar.css. The address looks like a stylesheet, and she only sees a page load.
  2. A miss. The edge has nothing stored for that path, so it forwards the request to the origin, cookie and all.
  3. A loose router. The router treats everything under /account/profile as the profile page, so it answers with Ana's profile as text/html, marked no-store.
  4. Stored anyway. The edge's rule keeps anything that ends in .css for a day and ignores the origin's Cache-Control, so Ana's profile is now stored under a stylesheet's address.
  5. The next visitor. Someone with no session asks for the same address. The key is the path, the same as Ana's, and the stored copy is fresh.
  6. Ana's profile leaks. The edge answers from its store. The visitor receives Ana's profile without the origin ever being asked, and keeps receiving it for a day.
  7. Fix 1: obey the origin. An edge that obeys Cache-Control keeps nothing marked no-store or private. The visitor's request then reaches the origin, which sends them to sign in.
  8. Fix 2: strict routes. A router that answers only /account/profile itself returns 404 for the longer path, so nothing personal exists under that address to store.
  9. Fix 3: check the type. An edge that stores a .css address only when the response is text/css never keeps an HTML page there. Each fix stops the leak alone; together they hold if one rule is changed.

Each fix in the figure breaks the chain at a different link. This program runs the same two requests under the weak setup and under each fix alone, and checks that the real stylesheet is still served from the cache:

One link, four setups: who gets Ana's profile JavaScript · deception/cache_sim.mjs
// Web cache deception, reduced to its three parts: an edge rule that caches anything ending like a static file and
// ignores the origin's Cache-Control, a router that answers /account/profile and anything under it, and a signed-in
// user who opens /account/profile/avatar.css (a link can take them there). Then a visitor without a session asks for
// the same address. Each fix is tried on its own; the real stylesheet must still be cached under every rule.

const EXTENSION = /\.(css|js|png|svg|woff2)$/;
const TYPE_OF = { css: 'text/css', js: 'text/javascript', png: 'image/png', svg: 'image/svg+xml', woff2: 'font/woff2' };

function origin(req, strictRoutes) {
  if (req.path === '/static/site.css') return { status: 200, type: 'text/css', cacheControl: 'public, max-age=86400', body: 'stylesheet' };
  const profile = strictRoutes ? req.path === '/account/profile' : req.path.startsWith('/account/profile');
  if (profile && !req.user) return { status: 302, type: 'text/html', cacheControl: 'no-store', body: 'to sign-in' };
  if (profile) return { status: 200, type: 'text/html', cacheControl: 'no-store', body: `profile of ${req.user}` };
  return { status: 404, type: 'text/plain', cacheControl: 'no-store', body: 'not found' };
}

function makeEdge({ obeysOrigin = false, checksType = false, strictRoutes = false }) {
  const store = new Map();
  return (req) => {
    const key = req.path; // the session cookie is not part of the key
    if (store.has(key)) return { ...store.get(key), from: 'cache' };
    const res = origin(req, strictRoutes);
    const ext = EXTENSION.exec(req.path)?.[1];
    let kept = ext !== undefined; // the edge rule: cache it for a day, whatever the origin says
    if (kept && obeysOrigin && /no-store|private/.test(res.cacheControl)) kept = false;
    if (kept && checksType && res.type !== TYPE_OF[ext]) kept = false;
    if (kept) store.set(key, res);
    return { ...res, from: 'origin', kept };
  };
}

const RULES = [
  ['cache by extension, loose routes', {}],
  ['fix 1: the edge obeys no-store', { obeysOrigin: true }],
  ['fix 2: unknown paths answer 404', { strictRoutes: true }],
  ['fix 3: type must match extension', { checksType: true }],
];

console.log(`${'Edge rule and router'.padEnd(34)} ${'Ana gets'.padEnd(15)} Kept  Next visitor gets`);
for (const [label, config] of RULES) {
  const edge = makeEdge(config);
  const ana = edge({ path: '/account/profile/avatar.css', user: 'Ana' });
  const visitor = edge({ path: '/account/profile/avatar.css', user: null });
  const leak = visitor.body.includes('Ana');
  const seen = `${visitor.status} ${visitor.body} (${visitor.from})`;
  console.log(`${label.padEnd(34)} ${`${ana.status} ${ana.type}`.padEnd(15)} ${(ana.kept ? 'yes' : 'no').padEnd(5)} ${seen.padEnd(29)} ${leak ? 'LEAK' : 'ok'}`);
}

const stillCached = RULES.every(([, config]) => {
  const edge = makeEdge(config);
  edge({ path: '/static/site.css', user: null });
  return edge({ path: '/static/site.css', user: null }).from === 'cache';
});
console.log(`\n/static/site.css is served from the cache under every rule: ${stillCached ? 'yes' : 'no'}`);

Output

Edge rule and router               Ana gets        Kept  Next visitor gets
cache by extension, loose routes   200 text/html   yes   200 profile of Ana (cache)    LEAK
fix 1: the edge obeys no-store     200 text/html   no    302 to sign-in (origin)       ok
fix 2: unknown paths answer 404    404 text/plain  yes   404 not found (cache)         ok
fix 3: type must match extension   200 text/html   no    302 to sign-in (origin)       ok

/static/site.css is served from the cache under every rule: yes

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node cache_sim.mjs

Only the weak setup leaked, and every fix kept /static/site.css in the cache, so none of them costs the cache its job. ASVS 5.0.0 requirement 14.2.5 asks for the second and third fixes by name: cache only responses of the expected content type, and answer a request for a file that does not exist with 404 or 302, never with another valid page. The first fix, an edge that obeys the origin, is nginx’s default behaviour. The danger there is a line added to raise a hit ratio, such as proxy_ignore_headers Cache-Control Set-Cookie;, which takes away the origin’s say over what is stored.

Checkpoint

Check: Unkeyed inputs and cache poisoning · Web cache deception and its fixes

  1. Question 1 of 3 Your page builds its script URL from X-Forwarded-Host, and the CDN keys on host, path and query. A test request sends X-Forwarded-Host canary.invalid to /?check=7f3a. What does a failing check tell you, and what fixes it?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: An unkeyed header changes a cacheable page; build the URL from configured PUBLIC_ORIGIN, or drop the header at the edge

    A canary that reaches a stored copy proves that an input outside the key shapes cached output. Remove the dependency: absolute URLs from configuration, forwarded headers only from your own proxy.

  2. Question 2 of 3 The edge keeps every address ending in .css for a day, and /account/profile/avatar.css returns the signed-in user's profile. Which changes stop the profile from being served to others? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 3

    Answer:

    • The edge stores a .css address only when the response is text/css
    • The router answers only /account/profile and returns 404 for longer paths
    • The edge obeys the origin's Cache-Control, and the profile is marked no-store

    Each fix breaks the chain at a different link, and each alone stopped the leak in the program while the real stylesheet stayed cached. Keep all three, so a later change to one rule does not reopen it.

  3. Question 3 of 3 What does ASVS 5.0.0 requirement 14.2.5 ask a server to return for a request for a file that does not exist?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: A 404 or a 302, never a different valid file

    Strict routing is half of 14.2.5; the other half is caching only responses of the expected content type.

Application caches need the requester in the key

Turning HTTP caching off does not end the problem, because the same bug lives inside applications. A Redis entry or an in-memory cache keyed by an object’s id serves whatever the first reader was allowed to see. Here are two weak caches of invoice summaries for two tenants, Acme and Globex, that both have an invoice numbered 1001, then the fix:

Two weak application caches and the fix Python · app/report_cache.py
# An application cache with the same bug as a shared one: the key does not say who asked. Two tenants, Acme and
# Globex, each have an invoice numbered 1001. Two weak caches, then the fix. Data and names are made up.
from functools import lru_cache

INVOICES = {("acme", 1001): 1200, ("globex", 1001): 87_500}
USERS = {"ana": "acme", "gita": "globex"}


def summary(tenant, number):
    return f"invoice {number} of {tenant}, total {INVOICES[(tenant, number)]}"


# Weak 1: keyed by the invoice number alone, and the tenant is looked up only on a miss.
BY_NUMBER = {}


def weak_by_number(user, number):
    if number not in BY_NUMBER:
        BY_NUMBER[number] = summary(USERS[user], number)
    return BY_NUMBER[number]


# Weak 2: lru_cache keys on the arguments, and the user is not one of them.
SESSION = {"user": None}


@lru_cache(maxsize=1024)
def summary_for_session(number):
    return summary(USERS[SESSION["user"]], number)


def weak_lru(user, number):
    SESSION["user"] = user
    return summary_for_session(number)


# Fixed: the tenant comes from the session and is checked on every request; the key holds it, and a version that is
# raised to empty the cache when permissions or the summary's format change.
FIXED = {}
VERSION = "v2"


def fixed(user, number):
    tenant = USERS[user]
    if (tenant, number) not in INVOICES:
        return "404"
    key = (VERSION, tenant, number)
    if key not in FIXED:
        FIXED[key] = summary(tenant, number)
    return FIXED[key]


for label, view in (("weak, keyed by number", weak_by_number), ("weak, lru_cache", weak_lru), ("fixed", fixed)):
    print(label)
    for user in ("ana", "gita"):
        shown = view(user, 1001)
        mark = "" if shown.endswith(str(INVOICES[(USERS[user], 1001)])) else "   <- another tenant's invoice"
        print(f"  {user} ({USERS[user]}) opens 1001: {shown}{mark}")

Output

weak, keyed by number
  ana (acme) opens 1001: invoice 1001 of acme, total 1200
  gita (globex) opens 1001: invoice 1001 of acme, total 1200   <- another tenant's invoice
weak, lru_cache
  ana (acme) opens 1001: invoice 1001 of acme, total 1200
  gita (globex) opens 1001: invoice 1001 of acme, total 1200   <- another tenant's invoice
fixed
  ana (acme) opens 1001: invoice 1001 of acme, total 1200
  gita (globex) opens 1001: invoice 1001 of globex, total 87500

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

Both weak caches showed Gita, of Globex, Acme’s invoice. The first keyed by the number alone and looked up the tenant only on a miss. The second used functools.lru_cache, which keys on a function’s arguments, while the user came from somewhere else. The fix decides access on every request, before any cache read, and keys by a version, the tenant and the invoice. OWASP’s cache guide asks for the same: the tenant and every attribute that decides access in the key, a check before any cached value is used, and short lives for cached decisions about access.

A cache hit that skips the permission check

Caching the finished answer of a function that also checks permission means the check runs once, for the first caller. Check first, on every request, then read the cache, and key it by everything the check depends on.

Test every layer the response passes through

On its way to the user a response meets five layers, from the routes of your own code to the browser, and four of them can keep a copy. Each has settings that can override the others. Test them one at a time, from the origin to the browser:

Interactive · stage by stage Five layers, five checks Step down the path a response takes, from the origin to the browser, and see what to test at each layer.
Five layers, five checks, step 6 of 6. Showing Origin routes (every route has a policy), Application cache (tenant and user in the key), Reverse proxy (obeys the origin's policy), CDN edge (two users, one canary), Browser cache (clean after sign-out).Origin routesevery route has a policyApplication cachetenant and user in the keyReverse proxyobeys the origin's policyCDN edgetwo users, one canaryBrowser cacheclean after sign-out

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

Every step as text
  1. Origin. List every route with the policy it sends, and fail the build when a route has none. Request a static-looking suffix under each signed-in route, such as /account/x.css, and expect a 404.
  2. Application cache. Ask for the same object as users of two tenants and compare the answers. Change a permission and check that the next request sees the change, not a cached result.
  3. Reverse proxy. Read the proxy's configuration for rules that override the origin, such as an ignored Cache-Control or Set-Cookie, and check that the key holds the host as well as the path.
  4. CDN edge. Load one signed-in URL as two users and compare the bodies, read Age and Cache-Status on the second answer, and send one header canary on a test-only query string.
  5. Browser cache. Sign out, press Back and reload from the cache; nothing personal may appear. Personal responses need no-store, and the sign-out response can send Clear-Site-Data with "cache".
  6. Every deploy. Run the five checks after every deploy and every change of a cache rule, against each environment you own, because a cache setting changes behaviour without any code change.

Two response headers make these tests readable. Age on a response means it was not produced or checked by the origin for this request (RFC 9111 section 5.1), so any Age on a personal page is a failed test. Cache-Status, standardised in RFC 9211, lists each cache that handled the response, with hit, or fwd and the reason it forwarded, and whether it stored the response. The RFC also warns that its key parameter helps anyone trying to poison a cache, so send that part only to your own test clients. OWASP’s Top 10:2025 maps the browser side of these mistakes, CWE-525, a browser cache holding sensitive information, to A06 Insecure Design, next to CWE-444, the request-parsing differences of the next lesson.

Key idea

Test caching where it happens: two users on one URL, a static-looking suffix on every signed-in route, one canary per input and sign-out followed by Back, after every deploy and every change of a cache rule.

Checkpoint

Check: Keys of application caches · Testing the cache path

  1. Question 1 of 2 Two tenants each have an invoice 1001. This is the first weak cache of the lesson's program. What is wrong with it?

    Read the code, then choose one answer.

    BY_NUMBER = {}
    
    
    def weak_by_number(user, number):
        if number not in BY_NUMBER:
            BY_NUMBER[number] = summary(USERS[user], number)
        return BY_NUMBER[number]
    Show the answer: Question 1 of 2

    Answer: The key is the number alone and the tenant is looked up only on a miss, so the second tenant gets the first tenant's invoice

    Check access on every request before reading the cache, and key by everything the check depends on: a version, the tenant and the invoice.

  2. Question 2 of 2 A test signs in as a second user and loads /account. The response carries Age 25 and the body names the first user. What has the test found?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A shared cache served a stored copy of another user's page, so the route's policy or a cache rule is wrong

    Two users on one URL is the basic test of a shared cache. Read Age and Cache-Status on the second answer, then compare the bodies.

Practise: an origin that is safe behind a cache

Fix the origin of a shop so that it can sit behind a shared cache. The sample tests put a model of a shared cache in front of your code, send a canary in Host and X-Forwarded-Host, and ask for a static-looking address under /account:

Exercise · Medium · JavaScript, Python

An origin that stays safe behind a shared cache

Fix respond(req) in origin.mjs (Python: respond(req) in origin.py), the origin of a shop that sits behind a shared cache. req has a path, the request headers with lower-case names, and user: the signed-in user's name, or null (Python: None) when nobody is signed in. Return an object (Python: a dict) with status, headers (lower case) and body.

  • **/**, the same for everyone: 200, content-type: text/html, cache-control: public, max-age=300, and a body with <link rel="canonical" href="https://shop.example/">, built from PUBLIC_ORIGIN.
  • **/assets/app.css**, a versioned file: 200, content-type: text/css, cache-control: public, max-age=31536000, immutable.
  • **/account**: signed in, 200 with content-type: text/html, cache-control: no-store and the body account of <user>; signed out, 302 with location: https://shop.example/sign-in and cache-control: no-store.
  • Any other path, /account/avatar.css included: 404, content-type: text/plain, cache-control: no-store.

No URL may come from host or x-forwarded-host: the sample tests send a canary in both. They also put a model of a shared cache in front of your origin and check that one user never receives another user's page.

JavaScript · Starter code · origin.mjs

export const PUBLIC_ORIGIN = 'https://shop.example';

/** The origin's answer to one request: { status, headers, body }. Starter: it works, but not behind a shared cache. */
export function respond(req) {
  const host = req.headers['x-forwarded-host'] ?? req.headers.host;
  if (req.path === '/') {
    return { status: 200, headers: { 'content-type': 'text/html' }, body: `<link rel="canonical" href="https://${host}/">` };
  }
  if (req.path === '/assets/app.css') return { status: 200, headers: { 'content-type': 'text/css' }, body: 'body{margin:0}' };
  if (req.path.startsWith('/account')) {
    if (req.user === null) return { status: 302, headers: { location: `https://${host}/sign-in` }, body: '' };
    return { status: 200, headers: { 'content-type': 'text/html', 'cache-control': 'max-age=60' }, body: `account of ${req.user}` };
  }
  return { status: 404, headers: { 'content-type': 'text/plain' }, body: 'not found' };
}
The sample tests · origin.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { respond, PUBLIC_ORIGIN } from './origin.mjs';

const HOST = { host: 'shop.example' };
const get = (path, user = null, headers = HOST) => respond({ method: 'GET', path, headers: { ...headers }, user });
const policy = (res) => String(res.headers['cache-control'] ?? '').toLowerCase();

// A shared cache in front of the origin: it keys on the path alone, as the lesson's models do, and stores what the
// response's policy lets it store.
function sharedCache() {
  const store = new Map();
  return (path, user) => {
    if (store.has(path)) return store.get(path);
    const res = get(path, user);
    const cc = policy(res);
    if (!/no-store|private/.test(cc) && /max-age|public/.test(cc)) store.set(path, res);
    return res;
  };
}

test('one user never receives another user’s account page', () => {
  const cache = sharedCache();
  assert.equal(cache('/account', 'Ana').body, 'account of Ana');
  assert.equal(cache('/account', 'Ben').body, 'account of Ben', 'the cache answered Ben with a stored copy');
  assert.ok(policy(get('/account', 'Ana')).includes('no-store'), 'the account page says no-store');
});

test('a static-looking path under /account is a 404 with nothing personal', () => {
  const res = get('/account/avatar.css', 'Ana');
  assert.equal(res.status, 404);
  assert.ok(!String(res.body).includes('Ana'), 'no personal data in the 404');
  assert.ok(policy(res).includes('no-store'), 'the 404 says no-store');
});

test('absolute URLs come from PUBLIC_ORIGIN, never from the request', () => {
  const canary = { host: 'canary.invalid', 'x-forwarded-host': 'canary.invalid' };
  const home = get('/', null, canary);
  assert.ok(home.body.includes(`${PUBLIC_ORIGIN}/`), 'the canonical link names the shop');
  assert.ok(!home.body.includes('canary.invalid'), 'the canary reached the page');
  const out = get('/account', null, canary);
  assert.equal(out.status, 302);
  assert.equal(out.headers.location, `${PUBLIC_ORIGIN}/sign-in`);
});

test('the versioned stylesheet is public for a year, the home page for five minutes', () => {
  const css = get('/assets/app.css');
  assert.equal(css.headers['content-type'], 'text/css');
  assert.ok(/public/.test(policy(css)) && /max-age=31536000/.test(policy(css)) && /immutable/.test(policy(css)), 'public, max-age=31536000, immutable');
  const home = get('/');
  assert.ok(/public/.test(policy(home)) && /max-age=300\b/.test(policy(home)), 'public, max-age=300');
});

test('every response states a policy, and only shared ones say public', () => {
  for (const res of [get('/account', 'Ana'), get('/account'), get('/nothing-here')]) {
    assert.ok(policy(res) !== '', `the ${res.status} response has a cache-control header`);
    assert.ok(!policy(res).includes('public'), `the ${res.status} response is not public`);
  }
});

Python · Starter code · origin.py

PUBLIC_ORIGIN = "https://shop.example"


def respond(req):
    """The origin's answer to one request: {status, headers, body}. Starter: it works, but not behind a shared cache."""
    host = req["headers"].get("x-forwarded-host") or req["headers"].get("host")
    if req["path"] == "/":
        return {"status": 200, "headers": {"content-type": "text/html"}, "body": f'<link rel="canonical" href="https://{host}/">'}
    if req["path"] == "/assets/app.css":
        return {"status": 200, "headers": {"content-type": "text/css"}, "body": "body{margin:0}"}
    if req["path"].startswith("/account"):
        if req["user"] is None:
            return {"status": 302, "headers": {"location": f"https://{host}/sign-in"}, "body": ""}
        return {"status": 200, "headers": {"content-type": "text/html", "cache-control": "max-age=60"}, "body": f"account of {req['user']}"}
    return {"status": 404, "headers": {"content-type": "text/plain"}, "body": "not found"}
The sample tests · test_origin.py
import re

from origin import PUBLIC_ORIGIN, respond

HOST = {"host": "shop.example"}


def get(path, user=None, headers=None):
    return respond({"method": "GET", "path": path, "headers": dict(headers or HOST), "user": user})


def policy(res):
    return str(res["headers"].get("cache-control", "")).lower()


def shared_cache():
    """A shared cache in front of the origin: it keys on the path alone, as the lesson's models do, and stores what
    the response's policy lets it store."""
    store = {}

    def ask(path, user):
        if path in store:
            return store[path]
        res = get(path, user)
        cc = policy(res)
        if not re.search(r"no-store|private", cc) and re.search(r"max-age|public", cc):
            store[path] = res
        return res

    return ask


def test_one_user_never_receives_another_users_account_page():
    cache = shared_cache()
    assert cache("/account", "Ana")["body"] == "account of Ana"
    assert cache("/account", "Ben")["body"] == "account of Ben", "the cache answered Ben with a stored copy"
    assert "no-store" in policy(get("/account", "Ana")), "the account page says no-store"


def test_a_static_looking_path_under_account_is_a_404_with_nothing_personal():
    res = get("/account/avatar.css", "Ana")
    assert res["status"] == 404
    assert "Ana" not in str(res["body"]), "no personal data in the 404"
    assert "no-store" in policy(res), "the 404 says no-store"


def test_absolute_urls_come_from_public_origin_never_from_the_request():
    canary = {"host": "canary.invalid", "x-forwarded-host": "canary.invalid"}
    home = get("/", None, canary)
    assert f"{PUBLIC_ORIGIN}/" in home["body"], "the canonical link names the shop"
    assert "canary.invalid" not in home["body"], "the canary reached the page"
    out = get("/account", None, canary)
    assert out["status"] == 302
    assert out["headers"]["location"] == f"{PUBLIC_ORIGIN}/sign-in"


def test_the_versioned_stylesheet_is_public_for_a_year_the_home_page_for_five_minutes():
    css = get("/assets/app.css")
    assert css["headers"]["content-type"] == "text/css"
    assert all(word in policy(css) for word in ("public", "max-age=31536000", "immutable"))
    home = get("/")
    assert "public" in policy(home) and re.search(r"max-age=300\b", policy(home))


def test_every_response_states_a_policy_and_only_shared_ones_say_public():
    for res in (get("/account", "Ana"), get("/account"), get("/nothing-here")):
        assert policy(res) != "", f"the {res['status']} response has a cache-control header"
        assert "public" not in policy(res), f"the {res['status']} response is not public"
A hint

Compare the path exactly (=== in JavaScript, == in Python) instead of startsWith, so that /account/avatar.css falls through to the 404. Build every absolute URL from PUBLIC_ORIGIN, and give every response a cache-control header: public only for the two responses that are the same for everyone.

The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or 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.

HTTP Header Checker Request a page you own and read its Cache-Control, Vary and Age headers, on every redirect too.

Then test what you now know about keys, policies and the layers of a cache; the questions adapt to your answers:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 14 What does RFC 9111 say a cache key is built from, at a minimum?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: The request method and the target URI

    The method and the URI, and since many caches store only GET, usually just the URL. Everything else that changes a response must be added through Vary, or kept out of the response.

  2. Question 2 of 14 During a test, ten different users load /account within a minute, but the origin's log shows one request for /account. What is the most likely reading?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: A shared cache answered nine of them from the first user's stored copy

    Personal pages must reach the origin every time. One origin request for many users means a shared cache keyed them together; fix the page's Cache-Control and purge what is stored.

  3. Question 3 of 14 A 200 response has no Cache-Control and no Expires, and its Last-Modified is 10 days before the response was sent. With the typical heuristic of RFC 9111, a tenth of that time, for how many seconds may a cache treat it as fresh?

    Type a number.

    Show the answer: Question 3 of 14

    Answer: 86400 seconds

    10 days is 864,000 seconds, and a tenth of it is 86,400 seconds, one day. That is why no Cache-Control at all is not the same as "do not cache": the program's account page without a policy was stored for 8,640 seconds.

  4. Question 4 of 14 After signing out on a library computer, the next person presses Back and sees the previous user's statement. The statement page sends private, no-cache. What do you change?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: Send no-store on the statement, and Clear-Site-Data with "cache" on the sign-out response

    For sensitive pages the browser is a cache too. no-store prevents the copy; Clear-Site-Data removes what earlier responses left behind, though it never reaches a shared cache.

  5. Question 5 of 14 What does a stored response with Vary * mean for a cache?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: It matches no later request, so it is never reused without revalidation

    Vary * says the response depends on something outside the request headers. Caches then cannot reuse it, so if that is what you want, say no-store instead.

  6. Question 6 of 14 Your page varies on Accept-Language, which browsers send in many spellings, but the origin only knows English and Hindi. The hit ratio is poor. What do you do?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: Normalise the header to en or hi at the edge, the same way the origin decides, and key on that

    Keep the key exact and small: vary on what the response depends on, reduced to the values the origin actually uses, with the same rule on every hop.

  7. Question 7 of 14 What is an unkeyed input?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: A part of the request that changes the response but is not part of the cache key

    An input either belongs in the key, through the URL or Vary, or must not change cacheable output. Forwarded headers, cookies and GET bodies are the usual suspects.

  8. Question 8 of 14 You want to check a production CDN for unkeyed headers without any visitor ever receiving a test response. How do you run the check?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: On a cache you own, against a URL with a query string that only the test uses, which the cache keys on

    A cache check stores responses, so it can affect real users. Keep the test's key apart from every visitor's key, and only touch caches you are responsible for.

  9. Question 9 of 14 Which combination produces web cache deception?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: A cache rule keyed on a static-looking extension, a router that answers longer paths with a personal page, and a signed-in user who opens such a path

    Web cache deception stores a personal page under an address that looks like a file. It needs all three together, and each fix of the lesson breaks the chain at the rule or at the router.

  10. Question 10 of 14 Users download their own invoices as /invoices/inv-1017.pdf, and someone proposes a CDN rule to cache every .pdf for a week. What do you say?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: Not for these files; a personal PDF is personal whatever its extension, so its response is no-store and the rule must obey that

    Extension rules are only safe for files that are the same for everyone. Personal files get no-store, and any cache rule must obey the origin.

  11. Question 11 of 14 What does functools.lru_cache use as the key of a cached call?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: The arguments of the call

    The key is the arguments, so everything that decides the result, the tenant and the user included, must be an argument, or the function must not be cached.

  12. Question 12 of 14 A user's access to a project is revoked, but cached project summaries live for an hour. How do you make the change take effect at once?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: Check access on every request before reading the cache, and raise the key version or delete the entries when permissions change

    A cache stores answers, not decisions about access. Decide access first, on every request, and key and version the cache so that a change of permissions retires old entries.

  13. Question 13 of 14 A response carries Cache-Status with the parameter hit for one of the caches. What does that cache say?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: It answered from its own stored copy, without forwarding the request

    Cache-Status lists each cache with hit, or fwd and the reason. On a personal page every cache should show fwd, and none stored.

  14. Question 14 of 14 Which checks belong in the test that runs after every deploy of a signed-in app behind a CDN? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 14 of 14

    Answer:

    • A static-looking suffix under each signed-in route returns 404 and is not stored
    • A header canary on a test-only query string never reaches a stored copy
    • Two users load the same signed-in URL and their bodies differ

    Each check targets one mistake of this lesson. Run them against every environment you own after each deploy and each change of a cache rule.

Interview questions

Warm-up: what is the difference between no-cache and no-store?

no-store tells every cache, the browser’s included, not to keep any part of the response. no-cache lets a cache keep it, but forbids reusing it until the origin has confirmed it is still valid, so a copy stays stored and every reuse costs a round trip. The names mislead: no-cache does not mean “do not cache”. Use no-store for sensitive responses, such as account pages, payment details or anything that must not stay on a shared computer after sign-out, and private, no-cache for personal pages that may stay on the user’s own device. Neither directive removes a copy that a cache stored earlier, so changing a policy also needs a purge.

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

  1. Mid to senior

    Right after your team put a CDN in front of the app, a customer reports seeing another customer's name and orders on their account page. No code changed. How do you find the cause, stop the leak and keep it from coming back?

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

  2. Mid to staff

    Your app serves public product pages and signed-in account pages from one host, and you are asked to put a CDN in front of all of it to cut the load. What policy and CDN rules do you set, and how do you show that no personal page can be shared?

    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 does a shared cache use to pick a stored response, and what protects a personal page?

    Its key: at minimum the request method and URL, which says nothing about the user. A session cookie does not make a response private, since Set-Cookie does not stop caching, so only the origin's Cache-Control decides whether a personal page may be kept.

  2. What do public, private, no-cache and no-store mean for a shared cache?

    public: may store, even for a request with Authorization. private: must not store, though the browser may. no-cache: may store but must revalidate before every reuse, so it does not mean "do not cache". no-store: must not store any part of it. s-maxage replaces max-age for shared caches.

  3. Which Cache-Control fits versioned files, shared pages, a user's own pages and sensitive responses?

    public with a year of max-age and immutable for versioned files; public and short for shared pages that change; private, no-cache for a user's own non-sensitive pages; no-store for everything sensitive and for any response you are unsure about (ASVS 5.0.0 14.3.2). no-store deletes nothing a cache already holds, so a policy change also needs a purge.

  4. Which three ways of answering an account page leaked it to the next user in the lesson's model?

    max-age=60 with a Set-Cookie; no Cache-Control at all, because a 200 with Last-Modified can get heuristic freshness, typically a tenth of the time since that date; and public on a response to a request with Authorization, which undoes the rule that protects such requests (RFC 9111 section 3.5).

  5. What does Vary do, and what does varying on too much cost?

    Vary lists the request headers that changed the answer, and a stored response is reused only for a request that sends the same values (Vary: * matches no request). Vary on exactly the headers the response depends on and normalise them at the edge; adding User-Agent took the lesson's cache from 8 keys to 309 and its hit ratio down to 74.2 %. Vary: Cookie is not an access rule.

  6. What is cache poisoning through an unkeyed input, and how do you prevent it?

    An input changes the response but not the cache key, a common one being X-Forwarded-Host used to build absolute URLs, so one odd request stores a harmful page for everyone with that key (RFC 9111 section 7.1). Build URLs from configuration, drop forwarded headers your own proxy did not write, never echo unkeyed request data into cacheable output, and test with a canary on a test-only query string.

  7. What is web cache deception, and what stops it?

    A cache stores anything that looks like a file (.css, .js), and a lenient route answers /account/profile/avatar.css with the signed-in user's page, so it is stored and then served to anyone who asks. Any one of three fixes breaks it: an edge that obeys the origin's no-store, strict routes that answer 404 for paths that do not exist, and caching only the expected content type (ASVS 5.0.0 14.2.5).

  8. What must an application cache, such as Redis or lru_cache, have in its key?

    The tenant and every attribute that decides access, with the permission check done on every request before any cached value is read. Caching the finished answer of a function that also checks permission runs the check only for the first caller.

Key takeaways

  • A shared cache answers from its key, the method and the URL, so nothing about the user protects a personal page: only the origin’s Cache-Control does.
  • Classify every response: public and long-lived for versioned files, public and short for shared pages, private, no-cache for a user’s own pages and no-store for anything sensitive, because cookies and Authorization are not policies.
  • Vary on exactly the headers a response depends on, normalised the same way on every hop: without Vary, 1,002 of 1,200 answers were wrong, and Vary: User-Agent cut the hit ratio to 74.2 %.
  • An unkeyed input must never change a cacheable response: build URLs from configuration, drop forwarded headers your own proxy did not write, and test with a canary on a test-only query string.
  • Web cache deception stops with any of three fixes, and is safest with all of them: no-store that the edge obeys, strict routes that answer 404, and a content-type check on extension rules.
  • Application caches need the tenant and the user in the key and the permission check before the read, and every layer of the path is tested after each deploy and each change of a cache rule.

Terms in this lesson

cache key
The information a cache uses to find a stored response for a request: at least the method and the URL, plus any request headers the response names in Vary. Two requests with the same key get the same stored answer.
cache poisoning
Getting a harmful response stored in a shared cache, so that the cache serves it to other users. It usually works through an unkeyed input that the origin reflects into a response it marks cacheable.
shared cache
A cache whose stored responses can answer many users, such as a CDN edge or a reverse proxy. A private cache, usually the browser's, belongs to one user. RFC 9111 forbids a shared cache to store a response marked private.
unkeyed input
A part of a request that changes the response but is not part of the cache key, such as a forwarded header or a cookie. Whatever it did to the stored copy is then served to everyone who asks with the same key.
web cache deception
A shared cache storing a personal page under an address that looks like a static file, such as /account/x.css, because the router answered the longer path and a cache rule keyed on the extension.

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.