A recipe for steganogravy

author's note The following justification is nonsense, I just thought the idea of encoding data in recipe blogs was silly.

With AI scrapers and government agencies roaming the internet and snarfing down every last byte, hoping to take advantage when you mistakenly publish useful information online, it’s gotten harder to share data without it becoming a future liability.

One wrong step and you find yourself accidentally contributing to your own jobs automation, having your identity stolen, or offending the kind of person that seems to always be complaining about other people getting offended.

What if we could hide data in a place no one would ever think to look? What if we could submerge our delicious morsels of knowledge in a flavorless slop so devoid of nutritional value even the most ravenous AI agents would spit it out?

tbrockman/recipe-blog-encoding

is a vibe-coded (and at least partially plagiarized1) Python CLI that allows you to encode data as completely natural-looking recipe blog introductions2 using neural linguistic steganography.

Given a shared prompt and a model, it can hide your valuable secrets where they’re least expected, online recipe introductions:

Looking for a quick and delicious way to impress your family and friends? This **One-Pan Garlic Butter Chicken with Herbed Potatoes** is the answer. Packed with flavor and easy to make, this dish is perfect for weeknight dinners or weekend feasts. What makes it stand out? It requires only one pan, minimizes cleanup, and allows the rich flavors to infuse perfectly while everything cooks.

The chicken is seasoned with garlic, onion powder, and a touch of paprika, then seared to golden perfection. But the real star here is the buttery herbed potatoes, tender and slightly crispy, soaking up every ounce of aromatic goodness. A quick toss with fresh parsley and oregano adds the warmth that makes this recipe so special.

This recipe is optimized for search engines with keywords like *easy one pan chicken recipe*, *garlic butter chicken*, and *herbed potato side dish*. Whether you're a seasoned chef or a beginner in the kitchen, this dish is a breeze to make and guaranteed to deliver big flavor.

[ ... ]

On the other side, knowing the original prompt and model used, you can recover the political messaging hidden in your favorite garlic butter chicken recipe:

python main.py decode --stego-file stego_output.txt --model "models/Qwen3-32B-Q4_K_M.gguf" --prompt "..."

# ... then after some processing ⌛

======================================================================
RECOVERED SECRET MESSAGE:
======================================================================
https://www.nokings.org/
======================================================================

Just how nona would have made it.

how it works

author's note To be a bit less verbose, we omit details like embedding a length header so the decoder knows how many bits to decode, using quantization so we're not operating on floats, and dealing with ambiguous re-tokenization when decoding

The implementation largely follows arithmetic coding steganography. At a high-level, you can imagine the following:

  1. We convert our secret into a binary fraction which represents a point somewhere on a number line between [0, 1).
  2. We use the model’s next token probability distribution to carve out adjacent intervals on the line, where the width of each interval is proportional to the token’s probability.
  3. We repeatedly choose tokens whose interval contains our point, narrowing the interval further and further, until enough of the leading bits of the start and end points of the interval agree, such that we’ve encoded our message.

Here’s a simple example with a 3-bit secret:

secret: 1 0 1
  → binary fraction: 0.101
  → point on [0, 1): 0.625

step 1: interval [0, 1)
  "The"      [0,    0.4)
  "Looking"  [0.4,  0.55)
  "This"     [0.55, 0.8)   ← 0.625
  "A"        [0.8,  1)
  → select "This", narrow to [0.55, 0.8)

step 2: interval [0.55, 0.8)
  " recipe"  [0.55, 0.7)   ← 0.625
  " is"      [0.7,  0.76)
  " One"     [0.76, 0.8)
  → select " recipe", narrow to [0.55, 0.7)

step 3: interval [0.55, 0.7)
  " is"      [0.55, 0.625)
  " uses"    [0.625, 0.67) ← 0.625
  " for"     [0.67,  0.7)
  → select " uses", narrow to [0.625, 0.67)

[0.625,  0.67) in binary:
[0.101..., 0.101...]
 ^^^        ^^^
leading bits agree: 1 0 1 → secret recovered ✓

The generated text would then read: “This recipe uses”

Decoding is just the reverse: run the same model with the same prompt, reconstruct the probability distribution at each step, and read the secret bits back out from which tokens appear in the text. It’s important to note that both sides need the exact same model, quantization, top-k, and prompt – any mismatch and the distributions diverge, producing garbage.

limitations

bpe tokenization

It turns out that if you pick a token during encoding, decode it to text, and then re-tokenize that text, you don’t always get the same token back. For instance, if the text so far tokenizes to [..., "hel"] and the model picks the "lo" as the next token, the combined text "hello" might re-tokenize as a single "hello" rather than "hel" + "lo". Then, when decoding, the decoder sees a completely different token at that position and everything after it diverges.

claude's fix: Add a filter that, at each step, tests whether a candidate token would survive a round-trip through decoding and tokenization. Tokens that wouldn’t are excluded from the CDF before any interval math happens. You lose some encoding capacity, but you can be certain that if your message can be encoded, it can also be decoded.

model end-of-sequence can be reached before the secret is fully encoded

question: What do we do if the prompt we’ve chosen doesn’t provide a path to generate sufficient tokens to encode our secret, converging on end-of-sequence before giving us enough bits?

answer: 🤷 choose a better prompt.

security

The prompt acts as a shared key, but it’s a leaky one. The generated text is statistically conditioned on the prompt, which is partially revealed by its own output (which is generally not regarded as an ideal property for an encryption scheme).

threat model: passing a note to your friend about who you like in class through an untrusted intermediary, or a border agent suspicious of your online presence

local LLM only

Not that it’s not possible to use any remote APIs (it should be so long as they provide sufficient determinism and logits), local’s just all that was implemented.

have fun cooking ✌️

  1. Subsequent investigation suggests that artkpv/arithmetic-coding-steganography/ is likely where Claude found inspiration for the implementation 

  2. Or whatever style you choose, it’s determined by the prompt