Guide · AI and GenAI
SGLang's October 2026 KV Cache Exposure Explained: Stale Memory Between Users
20 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- The SGLang KV cache exposure was a bug in SGLang, an open-source LLM serving engine. Over-long prompts could read memory left behind by other users' requests.
- The KV cache is the model's working memory for a prompt. SGLang can split work between prefill servers, which read the prompt, and decode servers, which write the answer.
- It hit Cloudflare's own deployment and was reported by an outside researcher. Cloudflare counted 155 responses between 27 August and 9 September 2026 whose replies contained other users' data.
- Only setups that split prefill and decode across servers were exposed. From 0.5.20, SGLang stops a refused request instead of passing it to decode. Cloudflare recommends 0.5.22 or later.
- Beginners can stop after the section on why nobody noticed. Engineers get a version-by-version source check of 0.5.19 to 0.5.22, a toy you can run, and a deployment checklist.
What goes wrong when an LLM server reads memory it never wrote?
It hands one user's private text to another user, and nothing looks broken. The reply arrives with a normal success code, reads fluently and contains someone else's data.
That is what the SGLang KV cache exposure did. SGLang is an open-source engine that runs large language models on GPUs. Under one specific set of conditions, a request could read memory still holding earlier requests' data.
Picture a common setup. A team self-hosts a large open-weight model with a very long context window, and splits the work across two groups of GPU servers to serve more users. The setup is made up, but the failure is the one Cloudflare reported.
One day a user pastes a huge document, longer than the memory the first group of servers has room for. Instead of an error, the user gets a confident answer that quotes a stranger's conversation.
Which words do you need before reading the SGLang KV cache report?
How did a stranger's conversation end up in that answer? Eight terms explain it, and each one comes back in the next three sections.
- Token: a small piece of text, about a word or part of a word. Models read and write tokens, and limits are counted in tokens.
- Context window: the longest input, in tokens, a model is configured to accept in one request.
- KV cache: the model's working memory for a request. KV stands for key and value, two lists of numbers the model stores for every token so it does not recompute them.
- Page: a fixed-size block of KV cache memory. The server hands pages to requests and takes them back when requests end.
- KV pool: all the pages one server has. Its size in tokens is the most context it can actually hold.
- Prefill: the first phase of a request, where the model reads the whole prompt and builds its KV cache.
- Decode: the second phase, where the model writes the answer one token at a time while reading that KV cache.
- Disaggregated serving: running prefill and decode on separate servers and copying the KV cache between them over a fast network link. SGLang calls this PD disaggregation.
What was the SGLang KV cache exposure disclosed in October 2026?
The SGLang KV cache exposure was a cross-request data leak in SGLang's disaggregated prefill and decode mode. It hit one model Cloudflare served on its hosted inference product, and an outside researcher reported it.
Cloudflare's post-mortem says 155 responses were affected, all to prompts longer than 681,280 tokens. The exposure ran from the model's launch on 27 August 2026 until 9 September 2026.
Cloudflare says it found no evidence of deliberate exploitation. It also says no other model it hosts was affected. Both statements are Cloudflare's own findings about its own service.
| Date | What happened |
|---|---|
| 27 Aug 2026 | The model launches with a 1,048,576-token context window but room for only 681,280 tokens on its prefill servers. |
| 8 Sep 2026, 23:11 | A researcher reports the leak through Cloudflare's bug bounty programme. |
| 9 Sep 2026, 13:22 | Cloudflare declares a security incident. Its team reproduces the leak with two independent accounts within 25 minutes. |
| 9 Sep 2026, 14:00 | The model is set to private, so new requests get an error. |
| 9 Sep 2026, 14:52 | All prefill servers move to a corrected configuration. |
| 9 Sep 2026, 19:40 | The model is public again, rebuilt with a patched engine. |
| 26 Oct 2026 | Cloudflare and the SGLang team publish their reports together. |
The report took seven weeks to become public because it was a coordinated disclosure. Cloudflare and the SGLang maintainers fixed the engine first, then explained it.
How did one request end up reading another request's data?
Two problems met. A configuration gap let a prompt be too big for the prefill servers, and an engine bug sent that oversized request onward instead of refusing it.
Here is an everyday picture before the details. Freed KV pages are like hotel rooms re-let without cleaning. The front desk turned a guest away but still handed over the keys. Its check asked only whether the stay had ended, not whether it had been refused.
That turned-away guest walks into rooms full of the last guest's papers and reads them out loud. Where the picture breaks: a hotel guest would notice the mess, while a model simply reads whatever memory it is given and never knows it is not its own.
The configuration gap
The model accepted prompts up to 1,048,576 tokens. The prefill servers' KV pool held only 681,280 tokens. Any prompt between those two numbers was allowed in but could not fit.
Cloudflare's fix for this part was simple. It matched the settings of an earlier model, so each prefill pool now holds about 2.04 million tokens.
The swapped prompt
When SGLang refuses a request, it does not stop it at once. It swaps the long prompt for a single token, called a stub, so the refused request stays cheap. In the 0.5.22 source this happens in a function named set_finish_with_abort, so the code calls a refusal an abort.
The refusal is written down as a note to act on later, in a field named to_finish. The request is not marked finished yet.
The check that asked the wrong question
Every queue that admits requests asked one question: is this request finished? That answer comes from a different field, finished_reason, which was still empty. So the refused request looked alive and passed every gate.
Cloudflare puts it plainly: the queues checked whether a request was finished, not whether it was rejected. The 0.5.22 source carries a comment saying the same thing about to_finish.
Decode reads pages nobody wrote
Prefill built KV cache for the one-token stub and sent it on. The decode server had reserved pages for the full prompt length, but only the stub's data arrived.
That copy uses RDMA, which means remote direct memory access. One machine writes straight into another machine's GPU memory without asking its CPU. Cloudflare used a transfer library called Mooncake for this.
The other reserved pages were not empty. They still held KV cache from earlier requests, because freed pages are reused without being wiped. Decode read them as if they were the prompt and wrote an answer from them.
How an over-long prompt reached stale memory
Drag to exploreNotice the gap between the reject and the decode step: the request was refused but kept moving because nothing checked the refusal.
Diagram connections:
- Long prompt to Prefill server (sends prompt)
- Prefill server to One-token stub (rejects)
- One-token stub to Admission check
- Admission check to KV transfer (lets it through)
- KV transfer to Decode server
- Decode server to Stale pages (reads unwritten pages)
- Stale pages to HTTP 200 reply
- Long prompt: User → prefill server, over 681,280 tokens
- Prefill server: Pool holds 681,280 tokens, so it rejects
- One-token stub: Prompt swapped, refusal stored in to_finish
- Admission check: Asks finished(), not rejected: passes
- KV transfer: Prefill → decode over RDMA, one token of KV
- Decode server: Reserves pages for the full length
- Stale pages: Still hold earlier requests' KV cache
- HTTP 200 reply: Decode → user, fluent text with leaked data
Why did nobody notice the leak for two weeks?
Because every signal a team usually watches said all was well. Cloudflare's report lists three of them.
- The status code was HTTP 200, which means success, instead of HTTP 400, which means a bad request.
- The prompt_tokens count in the response matched the long prompt, so usage numbers looked right.
- The reply was coherent. Language models write fluent text from whatever memory they read.
Cloudflare says it knew before launch that the pool was smaller than the context window. It expected the engine to refuse prompts it could not hold. Its long-context tests only checked HTTP codes and engine speed, so they passed.
The bug was not new, either. Cloudflare notes that another user had reported it to SGLang as issue 30233 on 6 July 2026. That report described decode reserving space for a full prompt but receiving one token of KV data.
One clue did exist. Cloudflare notes the leaked replies arrived faster than reading such a long prompt should take. A system that tracks time per prompt token could have flagged that.
How can you see the bug without a GPU?
You can run a small toy in plain Python. It is not SGLang code. It copies the shape of the bug: a shared page pool, pages reused without wiping, and a refusal that does not stop the request.
PAGE = 4 # tokens per page (toy size)
pool = [None] * 8 # shared KV pages on the decode server
free = list(range(8))
def alloc(n):
return [free.pop(0) for _ in range(n)]
def release(pages):
free[:0] = pages # freed pages are reused next; old data is NOT wiped
# Request A from customer 1 runs normally and finishes.
a = alloc(2)
pool[a[0]], pool[a[1]] = "A: card ends 4417", "A: ship to Pune"
release(a)
def serve(prompt_tokens, prefill_capacity, guard):
if prompt_tokens > prefill_capacity:
if guard:
return "HTTP 400: prompt too long"
sent = ["placeholder"] # prompt swapped for a one-token stub
else:
sent = [f"B page {i}" for i in range(-(-prompt_tokens // PAGE))]
pages = alloc(-(-prompt_tokens // PAGE)) # decode reserves the full length
for slot, data in zip(pages, sent):
pool[slot] = data # only len(sent) pages are actually written
seen = [pool[p] for p in pages]
release(pages)
return f"HTTP 200, decode reads {seen}"
print("old path:", serve(prompt_tokens=8, prefill_capacity=6, guard=False))
print("fixed path:", serve(prompt_tokens=8, prefill_capacity=6, guard=True))
print("normal request:", serve(prompt_tokens=8, prefill_capacity=16, guard=True))Save it as toy_kv_pool.py and run python3 toy_kv_pool.py with Python 3.8 or later. You should see exactly these three lines.
old path: HTTP 200, decode reads ['placeholder', 'A: ship to Pune']
fixed path: HTTP 400: prompt too long
normal request: HTTP 200, decode reads ['B page 0', 'B page 1']The first line is the leak. Request B was too long, so only its stub was written, and the second reserved page still held request A's shipping city. The status still says 200.
The second line refuses the request up front, which is what 0.5.21 added. The change that closed the real leak, in 0.5.20, works one step later: prefill retires the refused request instead of handing it to decode. The third line shows a normal request writing every page it reads.
Which SGLang versions fix the KV cache exposure?
Run SGLang 0.5.22 or later, as Cloudflare recommends. The leak path itself closed in 0.5.20, 0.5.21 turned on safer defaults, and 0.5.22 fixed a failed-send case that 0.5.21 still had. We checked each released wheel's source on 27 October 2026.
| Version (PyPI upload) | Refused request retired, not handed on | Refused at intake | Deferred decode KV release | KV checksum flag |
|---|---|---|---|---|
| 0.5.19 (4 Sep 2026) | No | No | Off by default | Not present |
| 0.5.20 (18 Sep 2026) | Yes | No | Off by default | Not present |
| 0.5.21 (1 Oct 2026) | Yes | Yes | On for Mooncake and NIXL | Present, off by default |
| 0.5.22 (16 Oct 2026) | Yes | Yes | On, MORI added | Present, off by default |
The deferred-release column is the fix for a second, related race, and the checksum is an extra guard; both come next. Mooncake, NIXL and MORI are the transfer libraries SGLang supports for copying KV cache between servers.
The change that closed the leak is pull request 36988. In 0.5.20, when prefill finishes a refused request, it retires the request instead of sending its last KV chunk to decode. In 0.5.19 the same code path still sends it.
Pull request 38935, first in 0.5.21, refuses such a request even earlier, before any transfer is set up. Its own description calls it a waste-and-latency fix, not a correctness fix. The 0.5.22 source calls a helper named is_unadmitted_reject for this.
Cloudflare's post says the patch shipped in 0.5.20 with follow-ups in 0.5.21 and 0.5.22, which matches our source check. Its timeline gives 2 October for 0.5.21 and 19 October for 0.5.22, one to three days after the wheels reached PyPI.
If you upgrade to 0.5.22, check your Python first. PyPI lists Python 3.11 or later for 0.5.22, while 0.5.21 accepted 3.10.
What is the second race that deferred KV release closes?
It is a timing bug where a copy finishes after the receiver has already given the memory away. It is related to the incident but separate from the over-long prompt path.
Here an abort means a request cancelled in the middle of a KV transfer, for example when the client disconnects. It is not the over-long refusal from earlier. RDMA writes for that request may still be on their way. The SGLang team writes that if decode releases and reassigns those slots before the writes complete, the late writes can overwrite another request's KV cache.
Deferred release fixes this by waiting. In the 0.5.22 source, an aborted request's pages are held until prefill confirms the transfer drained, or until a timeout, which defaults to 30 seconds.
A write that arrives after the timeout could still land in reused pages, so the timeout is a margin, not a guarantee. The SGLang post suggests 60 to 120 seconds through SGLANG_DISAGGREGATION_DEFERRED_DECODE_KV_RELEASE_TIMEOUT for a wider margin.
Late writes and deferred release
Drag to exploreFollow the two paths out of the abort: freeing at once lets a late write land in a stranger's pages, while waiting for the drain keeps them safe.
Diagram connections:
- Request aborted to Free pages now?
- Free pages now? to Pages reused (Yes (old default))
- Pages reused to Late RDMA write
- Late RDMA write to Corrupted request (lands in wrong pages)
- Free pages now? to Pages held (No (default from 0.5.21))
- Pages held to Safe reuse
- Request aborted: Client disconnects mid-transfer
- Free pages now?: Decode decides what to do with the slots
- Pages reused: Given to a new request at once
- Late RDMA write: Prefill → decode, arrives after the free
- Corrupted request: New request's KV overwritten
- Pages held: Kept until prefill acks the drain
- Safe reuse: Freed after ack or 30-second timeout
The SGLang post scopes this race to the cross-engine transfer path. It says a single-instance deployment without that path is not affected by this specific race. Default PD configurations were affected from the 0.4 line until 0.5.21 (0.5.22 for MORI).
It adds that deployments using the MORI transfer backend need a build that includes pull request 37077, which is in 0.5.22. The post also says failed Mooncake sends could still be confirmed as drained until pull request 43719, which is in 0.5.22. It lists the same fix for NIXL and MORI, pull request 45973, for 0.5.23, which was not on PyPI on 27 October 2026.
If you are stuck on 0.5.19 or 0.5.20 for now, the SGLang post says to set SGLANG_DISAGGREGATION_DEFERRED_DECODE_KV_RELEASE=1 on both servers. Both versions have the setting, but it is off by default. The post says it covers only aborts started by decode, the most common kind, so treat it as a stopgap until you upgrade.
How do you check your own SGLang deployment?
Check the version, compare pool size with context length on every server, and test answers for correctness at your longest allowed prompt.
Check the installed version
Run pip show sglang on every prefill and decode server. Move to 0.5.22 or later, which includes the failed-send fix for Mooncake. NIXL and MORI users should also watch for 0.5.23.
Read the pool size from the startup log
SGLang logs a line with max_total_num_tokens and context_len at startup. Find it in each server's log and write both numbers down.
Compare the two numbers
If max_total_num_tokens is smaller than context_len, a prompt can be accepted that cannot fit. Lower the context length or give the pool more memory.
Watch the same number as a metric
Start servers with --enable-metrics and read the gauge sglang:max_total_num_tokens. Re-check it after any change to weights, quantisation, parallelism or memory settings.
Turn on the KV checksum where it fits
Pass --disaggregation-enable-kv-checksum to both prefill and decode servers, from 0.5.21. It compares an Adler-32 checksum of each request's KV pages, and a mismatch aborts the request.
Test output, not just status
Send a prompt near your maximum length with a unique marker sentence, and check the answer refers to it. A 200 code with an unrelated answer is a failure.
For the log check, a quick search is enough. You expect one line per server process with both numbers in it.
pip show sglang | grep Version
grep -o 'max_total_num_tokens=[0-9]*.*context_len=[0-9]*' server.logA healthy result has the first number larger than the second, for example max_total_num_tokens=2040000 next to context_len=1048576. These values only echo the sizes in Cloudflare's report; read your own numbers from your log.
The checksum has limits. The SGLang post says both engines need the same KV layout and that it costs an extra GPU read and a sync point per transfer. Cloudflare says such checks do not yet cover every model architecture.
Cloudflare recommends turning on corruption detection where your model supports it, naming this checksum and a second check, --kv-canary. In the 0.5.22 source that flag takes none, log or raise, and defaults to none; its help text calls log production-safe. Treat both as extra guards on top of the upgrade.
What should teams running any LLM server learn from this?
Treat memory shared between users as a security boundary, and test it like one. The lessons apply to any engine that pools KV cache across requests.
- Refuse early and loudly. A request that cannot be served should return an error before it holds any resource, not carry a stored refusal through the system.
- Keep one source of truth for request state. Two fields that both mean done, read by different code, is how this bug slipped through.
- Do not trust success codes in tests. Check that answers match prompts, especially at the longest lengths you allow.
- Never assume an engine fails safely at a limit. Cloudflare knew the pool was smaller than the context window and expected a clean refusal; test that refusal directly.
- Add layers that make leaks impossible, not just unlikely. Cloudflare prototyped ownership tags on pages at under 1% throughput cost, disabled them over reliability problems and plans a production version.
Cloudflare also plans to model its KV transfer protocol formally, which means proving properties with a tool rather than testing examples. That work is described as future, not done.
How do you explain the SGLang KV cache exposure in a system design interview?
Use it as a story about isolation in shared memory. Interviewers want to hear that you see the KV cache as user data, not just a speed trick.
Name the shared resource
In LLM serving, KV cache pages are pooled across users for speed. Freed pages are reused without wiping, so stale data is usually present.
State the invariant
A request may only read pages written for that request. Every path, including errors and aborts, must keep this true.
Show how it broke
A rejected over-long request was not stopped, so decode read reserved pages that held other users' data.
Give layered fixes
Never hand a refused request onward, reject it at intake, hold pages until transfers drain, verify transfers with checksums, and size pools to the context window.
Add detection
Test outputs for correctness at maximum length, and alert on replies that are impossibly fast for their prompt size.
Questions people ask
- Was my data leaked if I used SGLang?
- Only if you ran it in disaggregated prefill and decode mode on an affected version. Cloudflare's leak needed prompts longer than the prefill pool; the SGLang team says single-node serving returned an error instead. The separate late-write race could also corrupt KV after aborted transfers, in default PD setups before 0.5.21 (0.5.22 with MORI).
- Is this the same as prompt caching leaking data?
- No. Prompt caching reuses KV cache on purpose when two requests share the same opening text. This bug read pages that were never meant to be shared at all, left over from unrelated requests. Cloudflare's report does not name prefix caching as a factor.
- Which SGLang version should I run?
- Run 0.5.22 or later, as Cloudflare recommends. Our source check found the leak path closed in 0.5.20 and deferred KV release on by default from 0.5.21. The 0.5.22 release adds a fix for failed Mooncake sends, and the SGLang post lists the same fix for NIXL and MORI in 0.5.23.
- Does turning on the KV checksum fix the bug by itself?
- No. The checksum compares each request's KV pages between prefill and decode and aborts on a mismatch. It is an extra guard against corrupted transfers. The over-long prompt path was closed by a code change in 0.5.20, and 0.5.21 refuses such requests earlier. Upgrade first and add the checksum on top.
- Why did the model give a sensible answer from someone else's data?
- Because a language model writes fluent text from whatever its attention reads. It has no way to tell that a page of memory belongs to another user. That is why the replies looked normal and the leak was hard to spot from the outside.
- Does this affect other serving engines like vLLM?
- This bug and its fixes are specific to SGLang's code. Other engines also pool KV cache, so the lessons apply, but neither report claims other engines were affected. Check each engine's own release notes and test over-long prompts on your own setup.
Sources and further reading
- Cloudflare: Cross-request KV cache exposure and remediation with SGLang (26 Oct 2026)
- SGLang team: Security update, improving request isolation (26 Oct 2026)
- SGLang issue 30233: aborted prefill requests and incomplete KV cache in PD mode
- SGLang pull request 36988: retire aborted disaggregated prefill results
- SGLang pull request 38935: do not admit intake-rejected requests to a PD handoff
- SGLang pull request 39500: optional KV transfer checksums
- SGLang 0.5.22 on PyPI (release files used for the source check)