← Back to blog

The decoy realism engine.

13 May 2026 · 7 min read

Deniable encryption works on a simple property. One ciphertext, one pair of passwords, but two control files, and the control file you feed back in is what decides which of two plaintexts you get out. Both plaintexts look like the kind of thing somebody would actually encrypt. An attacker with the ciphertext, with the source code, and with the willingness to apply pressure, cannot tell which plaintext is the one you cared about. They get a choice. The choice has to be a real choice.

The cryptographic part of that property is solved. AES, an XOR composition, two passwords, and a control file that decides which plaintext emerges. We have written about the construction at length on the Verify page and in the whitepaper. The construction is the easy part.

The hard part is the decoy. Specifically: where does it come from, and is it any good.

Why this is not a copy-paste problem

For the past eighteen months our decoys came from a hand-curated template pool. A few hundred entries across a handful of categories. Pick a category, sample a template, fill in placeholders, ship. It worked. It also had three problems we could not engineer around.

First, a static pool is a fingerprint. Anyone with the source code, the open weights of our SDK, or a single afternoon with our CLI can enumerate every template we ship. Once enumerated, every future decoy drawn from that pool can be flagged as a known-decoy string. The pool stops being a deniable plaintext and starts being a list of suspect plaintexts. A coercer who has done their homework no longer needs to guess. They check.

Second, the templates were generic. A burner wallet phrase. A made-up API key. A placeholder credit card number. None of them looked like your kind of burner wallet phrase, or your kind of API key. The shape was right. The texture was wrong. A forensic examiner reading the decoy would not immediately catch it, but they would notice that the decoy did not look like a thing somebody actually used.

Third, the pool was bounded by what we had time to write. A few dozen templates per category, capped at whatever the on-call engineer found realistic over a long afternoon. The cap meant repeats. The cap meant gaps. The cap meant we could not deny what we had not pre-written.

The infrastructure version of this product cannot have those three problems. So we built the realism engine.

What we shipped

The decoy realism engine generates a fresh, type-aware, shape-locked decoy per request. It runs in the hosted runtime (the Operate pillar), gated by your API key, rate-limited per tier, cost-capped at the platform level. It covers sixty-nine secret types out of the box: sixty-seven named credential shapes plus generic and freeform-secret as catch-alls for anything outside the named catalogue. Twenty-five of the named shapes have hard deterministic validators (Luhn for credit cards, mod-97 for IBANs, BIP39 checksum for wallet phrases, JWT structural, PEM tag, NHS Modulus 11 for UK NHS numbers, US SSN and UK NI structural rules, Bitcoin Base58Check, and E.164 phone format); the rest are shape-locked by template constraints (prefix, length, character set) without an external checksum. Generation is LLM-driven across multiple provider families. Validation is local, deterministic, and runs before the decoy ever leaves the server.

A representative slice of the catalogue (the full list lives at /tools):

01
Stripe test key (stripe-test-key)
sk_test_ prefix, 24+ alphanumeric body. Plausible body, invalid against the Stripe API.
02
Stripe live key (stripe-live-key)
sk_live_ prefix, 24+ alphanumeric body. Real-shape, inert at Stripe.
03
GitHub PAT classic (github-pat-classic)
ghp_ prefix, exactly 36 alphanumeric. Length-strict by construction.
04
GitHub PAT fine-grained (github-pat-fine)
github_pat_ prefix, 60+ chars from [A-Za-z0-9_]. Segment structure preserved.
05
OpenAI API key (openai-key)
sk- (optionally sk-proj-) + 40+ chars. Plausible byte distribution; will not authenticate against any OpenAI endpoint.
06
Anthropic API key (anthropic-key)
sk-ant- (optionally sk-ant-api03-) + 80+ chars. Real prefix shape, plausible character set. Never a key any Anthropic tenant could mint.
07
Resend API key (resend-key)
re_ prefix, 20+ chars from [A-Za-z0-9_]. Inert against the Resend API.
08
AWS access key (aws-access-key)
AKIA or ASIA prefix + 16 uppercase alphanumeric, total 20 chars. Will not pair with any real AWS IAM identity.
09
BIP39 wallet phrase (bip39-phrase)
12 or 24 words from the BIP39 list. Every candidate is checksummed and regenerated until the BIP39 checksum passes, so the decoy loads cleanly into any wallet. It just isn't the wallet with funds in it.
10
JWT token (jwt-token)
Three-segment header.payload.signature shape. Header base64url-decodes to plausible JSON. Payload and signature are base64url-shaped but not deeply structurally validated. Verifies against no key.
11
IBAN (iban)
Country code, check digits, BBAN. Every candidate is mod-97 verified and regenerated until the check digits pass. Decoys are arithmetically valid IBANs that resolve to no real account.
12
Credit card (credit-card)
Real-shape PAN with valid issuer prefix and length, 13 to 19 digits. Every candidate is run through the Luhn algorithm and regenerated until it passes. Decoys are Luhn-valid PANs that authorise against no real account.
13
Private key PEM (private-key-pem)
Correct -----BEGIN ... PRIVATE KEY----- header. Body is base64-shaped. Will not parse as a working keypair.
14
Postgres URI (postgres-uri)
postgres:// or postgresql:// connection string with credential, host, port, database, query parameters. Inert host.
15
MongoDB URI (mongodb-uri)
mongodb:// and mongodb+srv:// shapes. Plausible cluster naming, inert hosts.
16
GCP API key (gcp-api-key)
AIza prefix + 35 chars from [0-9A-Za-z_-]. Google-shaped, inert against Google APIs.
17
GCP service account key (gcp-service-account-key)
Compact service-account JSON with type, private_key_id, client_email, and embedded PEM-shaped private key. Shape-correct, never a working identity.
18
Azure client secret (azure-client-secret)
34 to 44 chars from [A-Za-z0-9_.~-] with a ~ delimiter. Entra-shaped, not valid for any app registration.
19
Azure storage key (azure-storage-key)
88-char base64 shape ending ==. Looks like an account access key, does not authenticate.
20
Generic (generic)
Catch-all for credential-shaped values without a specific named rule. Length and entropy targeted to the real one.
21
Freeform secret (freeform-secret)
Open-ended generation with type inference from a short context hint you provide. Used when the real value does not fit a named shape.

The remaining twenty-one named shapes (Slack tokens, Discord bot tokens, Cloudflare API tokens, HuggingFace tokens, Solana and Ethereum private keys, Bitcoin WIFs, Twilio/SendGrid/Mailgun/Resend keys, GCP and Azure cloud credentials, US SSN, UK NI and NHS numbers, E.164 phone numbers, and more) follow the same generate-then-validate pattern. The full enumerated list is at /tools.

For every entry with a hard validator (Luhn, mod-97, BIP39 checksum, NHS Modulus 11, SSN, NI, Bitcoin WIF structural), the post-generation check runs without exception. The decoy comes out of the model, gets parsed against the known structural rules for its type, gets checksummed where a checksum exists, and gets rejected if any check fails that a real value of that type would pass. A BIP39 phrase that fails its checksum is dropped and the model is asked again. A credit-card decoy that fails Luhn is dropped and the model is asked again. The validator is not optional and is not on the model's honour. It runs locally, deterministically, in the hosted runtime, before the response is ever serialised back to your client.

That property is the whole point. We are not asking the LLM to be careful. We are asking the LLM to be plausible, and asking the validator to make sure plausibility does not collide with reality.

What this looks like to a coercer

The threat model is unchanged. A coercer with the ciphertext, your two passwords, and the decoy control file decrypts a plaintext. The plaintext is, by construction, what we just described. It has the shape of a real credential of the type they expected to find. It is the right length. It passes every offline check (Luhn, mod-97, BIP39 checksum) but resolves to no live account at the system of record, which the coercer cannot query in the moment.

What changes with the realism engine is the next move. If the coercer thinks they have caught you out, their next step is to try and use the credential. They cannot. It does not authenticate. Which means one of two things, from their point of view: either they have caught you with a real-but-expired credential, or they have caught you with a decoy. The construction guarantees they cannot tell which.

That is the deniability. Algorithmic in the cryptography, operational in the decoy, structurally enforced at the boundary.

Why a generation engine, and not a bigger template pool

We could have hired a team and grown the template pool to a hundred thousand entries. Three reasons not to.

One. A hundred thousand entries is still a finite set. Anyone with read access to the pool, including any future malicious insider, gets the enumerable list of suspect plaintexts. A generation engine produces something new every time, and the output is not stored on our side at all.

Two. A template pool does not know about your context. The generation engine takes a short type hint and a free-form snippet, and produces a decoy that matches the texture of what you are encrypting, not just the shape. Your decoys for the credentials your platform actually uses will look like the kinds of credentials your platform actually uses.

Three. A generation engine improves. Every quarter the models get sharper, the validators get stricter, and the catalogue grows by adding generators rather than by adding rows. A static pool gets harder to maintain at scale. A generator gets easier.

The first reason is the security-critical one. The other two are the long-run reasons we will not regret this choice.

What we did not change

The cryptographic primitive is untouched. The library still ships Apache 2.0, in four languages, with reproducible test vectors. The SDKs do not call out to our hosted runtime by default. If you want to run deny.sh entirely offline with hand-written decoys, you still can. The realism engine is an opt-in capability of the hosted runtime, billed under your API key, available across all tiers with per-tier daily limits.

Your private data does not leave your client unless you ask for a decoy. When you do, the only thing the engine sees is your secret type and an optional short context hint. We do not see the secret. We do not see your password. We do not store the decoy we sent you. The audit log records the type, the timestamp, and the request id. That is the whole record.

Where this fits in the infrastructure

The decoy realism engine is the first piece of the AI-native moat we have been building toward. It is not a feature on the side. It is the part of the product that compounds. The engine improves with every release: more types, tighter validation, narrower acceptance bounds, smarter type inference. A competitor with a weekend and an LLM can ship a decoy generator. They cannot ship one that has been running in production against real traffic, with a real audit log, a real validator pipeline, and a real disclosure posture behind it. That is the kind of lead a generation engine accumulates by being live and by being part of an infrastructure stack rather than a standalone tool.

The open beta opens on Saturday 4 July 2026 at 08:00 BST. The realism engine is one of the things the open beta will be standing on. It is in the hosted runtime today, behind the same authentication, rate limits, and cost meters as the rest of the API. If you would like early access during the pre-beta window, tell us what you are building.

More posts on the blog · deny.sh · See the Operate pillar · Why deniability needs infrastructure