A result cache that leaks, and the fix
Break it on purposeNeeds Python
Problem
A result cache keyed only by the query text treats two users' identical SQL as one request. If the engine filters rows by who is asking (row-level security), the first user's filtered result is served to the next user, who may not be allowed to see it. The fix is to make the identity and the policy version part of the cache key.
Reproduce the shared-cache bug and prove the fix with a deliberate verification test. A query function filters rows by the user's region and caches results in a dictionary keyed by the query text. Run the same query as a north user, then a south user; then key the cache by query text, sorted roles, region and policy version, and repeat. Finally time a hit and a miss.
The question. Does the south user receive the north user's rows, and does the fixed key remove the timing signal?
Predict
Step 2 prints the wrong rows. After step 3 each user sees only their own region. The cache hit rate drops, which is the cost (costs are relocated, not removed).
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
a-result-cache-that-leaks-and.py # /// script # requires-python = ">=3.10" # dependencies = [] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """A result cache that leaks, and the fix. Reproduce the shared-cache bug and prove the fix with a deliberate verification test, including timing attacks.""" import argparse import time import json DATABASE = [ {"id": 1, "region": "north", "data": "North confidential sales: $1.2M"}, {"id": 2, "region": "north", "data": "North confidential pipeline: $3.4M"}, {"id": 3, "region": "south", "data": "South confidential sales: $0.8M"}, {"id": 4, "region": "south", "data": "South confidential pipeline: $1.1M"}, ] naive_cache = {} secure_cache = {} def execute_query(query_text, user_region, latency): """Simulates a database engine with row-level security.""" time.sleep(latency) if "SELECT *" in query_text: return [row for row in DATABASE if row["region"] == user_region] return [] def query_with_naive_cache(query_text, user, latency): """Vulnerable: Keyed only by the query text.""" cache_key = query_text t0 = time.time() if cache_key in naive_cache: lat = time.time() - t0 return naive_cache[cache_key], "HIT", lat result = execute_query(query_text, user["region"], latency) naive_cache[cache_key] = result lat = time.time() - t0 return result, "MISS", lat def query_with_secure_cache(query_text, user, latency, policy_version=1): """Secure: Keyed by query text, sorted roles, region, and policy version.""" identity_tuple = (query_text, tuple(sorted(user["roles"])), user["region"], policy_version) cache_key = hash(identity_tuple) t0 = time.time() if cache_key in secure_cache: lat = time.time() - t0 return secure_cache[cache_key], "HIT", lat result = execute_query(query_text, user["region"], latency) secure_cache[cache_key] = result lat = time.time() - t0 return result, "MISS", lat def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) parser.add_argument("--latency", type=float, default=0.05, help="Simulated database latency in seconds.") parser.add_argument("--json", type=str, help="Also write the results to this JSON file.") args = parser.parse_args() results = {"summary": {}, "tables": {"queries": []}} user_a = {"name": "User A", "region": "north", "roles": ["viewer"]} user_b = {"name": "User B", "region": "south", "roles": ["viewer"]} query = "SELECT * FROM sales" # --- Steps 1 & 2: The Leaking Shared Cache --- print("--- Steps 1 & 2: The Shared-Cache Bug ---") res_a, status_a, lat_a = query_with_naive_cache(query, user_a, args.latency) print(f"User A (North) Query -> Status: {status_a}, Rows returned: {len(res_a)}") results["tables"]["queries"].append({"step": "naive_user_a", "cache": "naive", "status": status_a, "latency_s": lat_a, "rows": len(res_a)}) res_b, status_b, lat_b = query_with_naive_cache(query, user_b, args.latency) print(f"User B (South) Query -> Status: {status_b}, Rows returned: {len(res_b)}") results["tables"]["queries"].append({"step": "naive_user_b", "cache": "naive", "status": status_b, "latency_s": lat_b, "rows": len(res_b)}) leak_occurred = any(r["region"] == "north" for r in res_b) print(f"Leak reproduced: User B received North rows? {leak_occurred}\n") results["summary"]["naive_leak_occurred"] = leak_occurred # --- Step 3: Fixed Cache with Identity Context --- print("--- Step 3: The Per-Identity Key Fix ---") res_a_sec, status_a_sec, lat_a_sec = query_with_secure_cache(query, user_a, args.latency) print(f"User A (North) Secure Query -> Status: {status_a_sec}, Rows: {len(res_a_sec)}") results["tables"]["queries"].append({"step": "secure_user_a", "cache": "secure", "status": status_a_sec, "latency_s": lat_a_sec, "rows": len(res_a_sec)}) res_b_sec, status_b_sec, lat_b_sec = query_with_secure_cache(query, user_b, args.latency) print(f"User B (South) Secure Query -> Status: {status_b_sec}, Rows: {len(res_b_sec)}") results["tables"]["queries"].append({"step": "secure_user_b", "cache": "secure", "status": status_b_sec, "latency_s": lat_b_sec, "rows": len(res_b_sec)}) secure_isolation = all(r["region"] == "north" for r in res_a_sec) and all(r["region"] == "south" for r in res_b_sec) print(f"Verification passed (strict isolation)? {secure_isolation}\n") results["summary"]["secure_isolation_passed"] = secure_isolation # --- Step 4: Timing & Side-Channel Signals --- print("--- Step 4: Timing Signal Demonstration ---") naive_cache.clear() _, _, miss_latency = query_with_naive_cache(query, user_a, args.latency) _, _, hit_latency = query_with_naive_cache(query, user_b, args.latency) print(f"Naive Cache - Miss Latency: {miss_latency * 1000:.2f}ms | Hit Latency: {hit_latency * 1000:.2f}ms") secure_cache.clear() _, _, a_latency = query_with_secure_cache(query, user_a, args.latency) _, _, b_latency = query_with_secure_cache(query, user_b, args.latency) print(f"Secure Cache - User A Latency: {a_latency * 1000:.2f}ms | User B Latency: {b_latency * 1000:.2f}ms") results["summary"]["timing_naive_miss_s"] = miss_latency results["summary"]["timing_naive_hit_s"] = hit_latency results["summary"]["timing_secure_user_a_s"] = a_latency results["summary"]["timing_secure_user_b_s"] = b_latency if args.json: with open(args.json, "w") as f: json.dump(results, f, indent=2) if __name__ == "__main__": main()3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run a-result-cache-that-leaks-and.py uv run a-result-cache-that-leaks-and.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --latency0.05 | Simulated database latency in seconds. |
Our run
The same query, two users
| Step | Cache | Latency | Rows |
|---|---|---|---|
| naive_user_a | MISS | 55 ms | 2 |
| naive_user_b | HIT | 0.954 µs | 2 |
| secure_user_a | MISS | 50.2 ms | 2 |
| secure_user_b | MISS | 55 ms | 2 |
--- Steps 1 & 2: The Shared-Cache Bug ---
User A (North) Query -> Status: MISS, Rows returned: 2
User B (South) Query -> Status: HIT, Rows returned: 2
Leak reproduced: User B received North rows? True
--- Step 3: The Per-Identity Key Fix ---
User A (North) Secure Query -> Status: MISS, Rows: 2
User B (South) Secure Query -> Status: MISS, Rows: 2
Verification passed (strict isolation)? True
--- Step 4: Timing Signal Demonstration ---
Naive Cache - Miss Latency: 55.06ms | Hit Latency: 0.00ms
Secure Cache - User A Latency: 55.06ms | User B Latency: 52.46msWhat we found
With the text-only key, the south user's query was a HIT and returned the north user's rows (leak: yes). With identity and policy in the key, the south user's query was a MISS and each user saw only their own region (isolation test passed: yes). That miss is the cost the chapter names: the second user no longer benefits from the first user's result.
The timing signal is large: a shared-key hit took 0.954 µs against 55.1 ms for a miss, so the south user could tell someone had already run the query. With per-identity keys both users paid a full miss (55.1 ms and 52.5 ms), and the signal was gone.
Read it
A cache is a copy of a decision made for one identity; serving it to another skips the access check. Every cache in front of a policy-filtered engine must key by everything the policy depends on, including the policy's version, so that a policy change does not keep serving old results. The lower hit rate is not a regression to be optimized away; it is the price of isolation.