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.
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- Ana's page. Ana receives her own page. So far everything looks right, which is why this mistake survives testing with one account.
- 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.
- 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.
- 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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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:
# 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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
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 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- 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.
- A miss. The edge has nothing stored for that path, so it forwards the request to the origin, cookie and all.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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:
# 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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".
- 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
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 fromPUBLIC_ORIGIN. - **
/assets/app.css**, a versioned file: 200,content-type: text/css,cache-control: public, max-age=31536000, immutable. - **
/account**: signed in, 200 withcontent-type: text/html,cache-control: no-storeand the bodyaccount of <user>; signed out, 302 withlocation: https://shop.example/sign-inandcache-control: no-store. - Any other path,
/account/avatar.cssincluded: 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
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.
-
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?
-
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?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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).
-
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.
-
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.
-
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).
-
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-Controldoes. - Classify every response:
publicand long-lived for versioned files,publicand short for shared pages,private, no-cachefor a user’s own pages andno-storefor anything sensitive, because cookies andAuthorizationare 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, andVary: User-Agentcut 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-storethat 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
- RFC 9111: HTTP Caching (IETF (RFC Editor))
- RFC 9211: The Cache-Status HTTP Response Header Field (IETF (RFC Editor))
- RFC 2606: Reserved Top Level DNS Names (IETF (RFC Editor))
- Web Cache Security Cheat Sheet (OWASP Foundation)
- Application Security Verification Standard 5.0.0: V14 Data Protection (OWASP Foundation)
- A06:2025 Insecure Design (OWASP Foundation)
- HTTP caching (MDN Web Docs (Mozilla))
- Vary header (MDN Web Docs (Mozilla))
- Clear-Site-Data header (MDN Web Docs (Mozilla))
- Module ngx_http_proxy_module: proxy_cache_key, proxy_cache_valid, proxy_ignore_headers (nginx)
- functools: lru_cache (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress