Reference

SVG, Rust and Elm: the security gaps illustration

For architects, CISOs and developers who need one picture of where PII, card data and PHI are actually exposed.

Base64 changes how data is written, not who can read it. Compiling changes what code can do. TLS protects the wire, not the endpoints. Sandboxes limit reach, not readership. Only encryption, server-side authorization and contracts decide who can read the data.


From cron to agents: who binds the systems, and where security has to live

Before APIs, data mostly stayed inside one cloud or one data center. When it had to move, a developer wrote an EDI or ETL mapping, someone reviewed it, and a cron job ran it — nightly, or every 15 minutes. The binding between systems happened at build time, and security lived at the perimeter, on the SFTP server and in the reviewed mapping.

APIs and iPaaS (integration platform as a service: hosted tools such as MuleSoft, Celigo or Boomi that connect one system's API to another's) moved the binding to event time: every order or update travels on its own, in seconds, through integration code. Security moved into API keys, scopes and signed webhooks.

AI agents move it to runtime. A model decides, per request, which systems to connect and what data to move — and the same model can be steered by anything it reads. So AI now does much of the binding between systems. It must not do the security. If the thing that chooses the connection also decides what is allowed, the security is only as strong as the model's resistance to a well-phrased instruction.

From cron to agents. Three lanes. Cron EDI and ETL: an ERP's batch file waits in an SFTP drop folder until a cron job runs a reviewed mapping into a warehouse or storefront. APIs and iPaaS: a Shopify order becomes a signed webhook that an iPaaS or Worker flow maps and forwards to the ERP, CRM and Google Merchant within seconds. AI agents: a person asks an agent for an outcome; the agent proposes, and a deterministic gate checks the user and agent identity, a signed mandate and a tool allowlist, writes every decision to a ledger, and only then reaches Shopify, Xano, Google Merchant or SAP. Four cards show where security has to live: the data, the identity, the authority and the gate. A table compares the three eras.

Open the SVG full size (1600 × 1090) · Download the PDF · JPG.

EraWho binds the systemsWhenWhere security livedWhat fails
Cron EDI / ETLA developer's mapping, reviewedOn a scheduleThe perimeter, SFTP, the mappingPlaintext batch files (gap 31), stale data
APIs and iPaaSIntegration code or iPaaS flowsPer event, in real timeAPI keys, scopes, signed webhooksBroad keys (M1), middleware copies (gaps 18, 28)
AI agentsA model, choosing at runtimePer request, on the flyOutside the model: a deterministic gateInjection (A1), excessive agency (A2), token passthrough (A3)

Security can no longer ride along with the binding. It has to attach to four things the model can't rewrite:

  1. The data. Consent state is a field on every record, and a record without it is refused, whoever asks (M3, gap 20).
  2. The identity. The user and the agent are each identified, and every token is scoped to one tool's audience (A3).
  3. The authority. A signed mandate carries the amount, merchant, expiry and a one-time nonce, and it is checked on every call (A4).
  4. The gate. Deterministic code at the endpoint says yes or no, and writes every grant and denial to an append-only ledger (A7).

The model proposes. Deterministic systems decide. Every grant and denial is recorded. The rest of this article maps where that breaks today, from a single SVG file to SAP.

The security gaps illustration

The full illustration, top to bottom: one SVG with a hidden script and patient metadata, the same file as a base64 data URI, and the three ways a page can display it; definitions of base64, compile, machine code and bare metal; what each layer checks and misses from an Elm client through TLS, Cloudflare, Xano and a Rust service; TLS hops in Next.js hydration and a CRM-to-Shopify data bus; JVM, iPaaS and Wasm stacks compared on isolation, network access, memory safety, supply chain, patching, Consent Mode v2 and idempotency; VM, Docker and Kubernetes isolation and transport; and 31 numbered security gaps, each with a fix.

Open the SVG full size (1600 × 4290) · Download the PDF (233 KB, one vector page) · JPG. The diagram is shown here as an image, never inlined, so its SVG cannot run code on this page — gap 3, applied to itself.

How to read it

MarkMeaning
Solid boxData passes through and a control holds
Red dashed boxA gap: data is stored, cached or exposed there, or code can run
Red dashed circle with a numberOne of the 31 gaps listed at the bottom of the diagram and below
Red dashed arrowTraffic that is plaintext by default (between Kubernetes pods)
Grey arrow labeled TLSOne TLS session; the data is decrypted in the box at each end
"decrypts here"Inside this box the data is plaintext, whatever the arrows say

The diagram reads top to bottom in seven bands:

  1. One SVG, three ways in. The source holds a hidden <script> and patient details in <metadata>. Base64 turns it into a data: URI without changing any of it. Display decides whether the script runs: never through <img>, always when inlined or loaded in <object> or <iframe>.
  2. Four words that get confused. Base64 is an encoding. A compile translates source into a different program. Machine code is what the CPU runs, and it disassembles. Bare metal is code with no operating system underneath, so a memory bug there owns the device.
  3. The request path. Six boxes from the Elm client to a Rust service, each split into what it checks and what it doesn't.
  4. TLS protects each hop, not the data. Lane A: a Next.js server render followed by client-side hydration. Lane B: a CRM-to-Shopify data bus.
  5. Same data, three stacks. Lane C is JVM middleware (Salesforce, MuleSoft, Salesforce Commerce Cloud). Lane D is an iPaaS chain (SAP, Azure, Celigo, Magento). Lane E is Wasm (Shopify Functions, Cloudflare Workers). A table compares them on isolation, network access, memory safety, supply chain, patching, Consent Mode v2 and idempotency.
  6. Isolation and transport. Where the wall sits in a virtual machine, a Docker container and a Kubernetes cluster, and how data crosses it.
  7. The gaps. All 31, each with its fix.

SVG: a picture, or a program

An SVG is XML. Besides shapes it can carry <script>, onload= handlers, <foreignObject> with embedded HTML, outside references, and text and metadata that hold names and IDs. Base64 keeps every one of those, about a third larger, and hides <script from any filter that only reads text.

<img src> renders in the browser's image sandbox: no script, no outside references, no DOM. Inlining promotes the file to a node in your document, where script runs. Safe SVG for Webflow and UIkit shows how to keep stylable, animatable SVG without spending that sandbox: an allow-list sanitizer, then inline. For anything an uploader supplied, display it with <img>, send Content-Security-Policy: sandbox on .svg responses, and serve uploads from a separate asset domain.

Treat an exported chart as the data it shows. An SVG of patient results is PHI, wherever it is cached or shared — the same point What Survives the Transform makes about XMP inside media files.

Read by a model: what the eye sees and what the model reads

An SVG is text, which is why a crawler or a language model can read one without a vision model guessing at pixels. The <title> and <desc> inside each diagram on this site tell the whole story in words for that reason. The same property is the risk: what a model reads and what a person sees can be two different messages. Text a reviewer never sees still reaches the model:

Hidden from the eyeHow
Invisible textfill the same as the background, opacity="0", or a font a fraction of a pixel high
Text off the canvasPlaced outside the viewBox, or inside a <clipPath> or <mask> that hides it
Text that is never drawnInside <defs>, <symbol> or <metadata>, with no <use> that renders it
A description that disagreesA <title> or <desc> that claims what the picture doesn't show

Each one is a ready-made prompt injection: an instruction the person approving the file can't see and the agent reading it can. The fix is to give the model only what the eye gets:

  1. Normalize before reading. Parse with usvg, the Rust parser behind resvg. It resolves CSS, expands <use>, and drops scripts and unknown elements into one simple tree. Check that tree, not the raw markup.
  2. Extract only visible text. Keep text that sits inside the canvas, isn't clipped or masked away, has real opacity and size, and contrasts with what is behind it.
  3. Compare the words with the picture. When <title> or <desc> says something the visible text doesn't, flag the file instead of passing it on.
  4. Hand it over as data. The extracted text goes to the model inside a marked, untrusted document block, never next to its instructions — typed, gated and ranked before it is tokenized, as Hyperscaler vs Distributed puts it. OWASP lists this indirect route under LLM01, prompt injection.

The goal is one message: the file tells a person and a model the same thing.

Rust: ownership as access control

Rust appears three times in the diagram. Each idea maps to a chapter of The Rust Book:

pub fn send_to_ads(data: Pii<Email>, grant: ConsentGrant) -> Result<(), Error> { /* … */ }

let grant = consent::verify(&req)?;   // the only way to get one
send_to_ads(email, grant)?;
send_to_ads(other_email, grant)?;     // compile error: use of moved value `grant`

The limit is printed on the Rust box: types force a check to happen; they cannot prove the check is the right one.

Where memory gets managed: compile time, not runtime

Memory safety, simply: a program is memory-safe when it can only read and write the memory it is allowed to, and only while that memory is still valid. It can't write past the end of a buffer, use memory after freeing it, free it twice, or read memory nobody initialized. In a memory-unsafe program, one malformed input can do exactly those things — and that is how a bad file becomes an attacker running their own code inside your process. The Chromium project finds that around 70% of its serious security bugs are memory-safety problems, which is why CISA asks software makers to publish roadmaps to memory-safe languages.

A common mental model is that Rust compiles to bytecode and a runtime manages its memory, the way the JVM does. Neither is true, and that is the point of Rust. The JVM manages memory at runtime; Rust proves it at compile time.

Where memory gets managed. Five pipelines split by a line between build time and run time. Rust: the compiler and borrow checker prove each memory access and insert each free, then ship native machine code that runs with no runtime manager. Rust to Wasm: the same proof, shipped as a Wasm module that V8 or Wasmtime compiles at load and walls off, still with no garbage collector. Java and Kotlin: javac ships bytecode, and the JVM's JIT and garbage collector manage memory while running. Elm and TypeScript: compiled to JavaScript, with V8's garbage collector at runtime. C: machine code with no ownership check, and memory freed by hand. Three cards draw the security consequences.

Open the SVG full size (1600 × 820) · Download the PDF · JPG. The black tag marks where memory is managed in each pipeline.

LanguageWhat the compiler producesBecomes machine codeWho frees memoryWhen safety is checked
CMachine codeAt build timeYou (malloc / free)Never: bugs become exploits
RustMachine codeAt build timeCompiler-inserted drops, decided by ownershipAt compile time; only bounds checks run
Java / Kotlin (JVM)BytecodeWhile running (JIT)A garbage collectorAt runtime
C# / .NETIL bytecodeWhile running (JIT)A garbage collectorAt runtime
JavaScript / TypeScriptJavaScriptWhile running, in V8A garbage collectorAt runtime
ElmJavaScriptWhile running, in V8JavaScript's garbage collectorTypes at compile time; memory at runtime
Rust to WasmA Wasm module (portable bytecode)When the host loads itRust's ownership, inside the host's memory wallCompile time, plus the host's sandbox

Rust to Wasm is the one case with bytecode, and even there nothing manages memory at runtime: the host only puts a wall around the module's memory.

Three consequences for security:

Why memory safety matters for AI and the machine layer. Prompt injection attacks the model's reasoning. Memory bugs attack the machine underneath it, and AI makes them easier to reach:

Is a garbage collector like Redis or a cache? Only in that both decide when something is removed. A garbage collector reclaims memory inside one process, and only what nothing refers to any more, so it never removes something in use. A cache such as Redis or Workers KV is a separate store of copies, shared across processes, that removes entries by policy (expiry, least recently used) even if someone will ask for them again. Neither one erases securely: memory freed by a garbage collector keeps its bytes until it is reused, and an expired cache entry may survive in snapshots and replicas. A cache is also one more copy of the data — sessions and PII in a cache need the same access controls as the database (gaps 6 and 11).

Parsing an untrusted SVG: what the compiler catches, and what it doesn't

An uploaded SVG is a stranger's instructions, and every number in it can be a trap: a width of four billion, a path that claims more points than it holds, a reference to an element that doesn't exist, groups nested a hundred thousand deep. A C renderer depends on the programmer to check each one; miss one and memory gets written past its end. librsvg, the SVG library used by GNOME, ImageMagick, libvips and Wikipedia's thumbnails, began in C and was rewritten in Rust for this reason; resvg was written in Rust from the start. The Rust compiler turns each check into a rule:

What Rust enforcesThe SVG trap it stops
Bounds checks: indexing is checked, and a bad index stops the program instead of reading past the endA path whose point count is larger than its data
Ownership: each value has one owner and is freed exactly onceAn element freed while a <use> still points at it (use-after-free)
Borrowing: many readers or one writer, never bothStyle or gradient data changed while the renderer reads it
No null: a reference that may be missing is an Option, and the code must handle Nonehref="#doesnotexist" crashing the parser
Checked arithmetic: checked_mul turns overflow into an error to handlewidth × height × 4 wrapping to a small number, a buffer too small, and a write past its end

Most of this is decided before the program runs: code that could break memory does not compile, so that class of bug never ships.

What the compiler does not catch:

Elm: no hidden side effects

Elm is the first box of the request path. Functions are pure, so a view that renders patient data cannot send it anywhere. Opaque types decide in one module what any screen may show. Ports are the only exit to JavaScript, which gives you one list to audit, and Elm packages cannot contain JavaScript.

The box's "does not" side is gap 8. The browser is untrusted: anyone can edit the compiled JavaScript or call the API directly. Elm protects your users from your own bugs, not your server from attackers — the same split the Dark Factory article draws between the Elm Architecture and Webflow + Xano. Every rule has to be enforced again in the Worker or Xano.

Wasm, containers and VMs limit reach, not readership

A Wasm module gets its own linear memory and can only call what its host gives it. A Worker runs in its own V8 isolate. A container shares the host kernel; a VM brings its own. Each is a wall around what code can reach. None decides who can read the data inside it. Where Your Code Runs Is a Business Decision prices the same options by cost and risk.

Two container facts tie straight back to band 1. Kubernetes Secrets are base64 in etcd until you turn on encryption at rest — gap 24 is gap 1 again. And pod-to-pod traffic is plaintext by default until a NetworkPolicy and mTLS or WireGuard say otherwise.

Gap index and fixes

Where each numbered circle appears, and the control that closes it.

#GapWhere it appearsFix
1Base64 is not encryptionBand 1, base64 data URIEncrypt or tokenize; never base64 a secret, key, token or card number
2Encoding hides payloads from filtersBand 1; Cloudflare edgeDecode first, then sanitize
3SVG executes when inlinedBand 1; Xano; Magento (lane D)Display with <img>; CSP sandbox; a separate asset domain
4CORS is not access controlCloudflare edgeServer-side authorization per record; deny by default
5The edge sees plaintextTLS; Cloudflare edge; TLS Full (strict)Full (strict); a BAA before PHI; field encryption
6Logs keep what they're sentXano; lanes A and BScrub fields or disable body logging; redacting types
7SVG carries data, not just pixelsBand 1, metadata and <img>Strip metadata; classify an export as the data it shows
8The client is untrustedBrowser · ElmEnforce every rule again on the server
9Compiled is not hiddenMachine code definitionSecrets stay server-side; ship only scoped, short-lived tokens
10Hydration ships data into the pageBrowser hydrates (lane A)Pass only what the screen needs; fetch PII after consent
11Shared caches mix customersCDN · edge (lane A)Cache-Control: private, no-store on personalized routes
12Server fetch runs before consentBrowser request; Next server (lane A)Gate server fetches on the consent cookie (Consent Resolution)
13NEXT_PUBLIC_ compiles into the bundleNext server (lane A)Secrets server-only; scan the built bundle in CI
14TLS is hop by hop on a data busCloudflare Worker (lane B)Sign messages; encrypt fields end to end; mTLS
15Unverified or replayed webhooksShopify webhook back (lane B)Verify the HMAC on the raw body; reject stale events
16Queue rows outlive the messageQueue · webhook table (lane B)Store IDs, not payloads; purge processed rows
17Admin tokens at the edgeCloudflare Worker (lane B)Least-privilege scopes, short lifetimes, rotation (Shopify Token Management)
18Middleware sees everythingMuleSoft; SFCC (lane C)Mask fields in transforms; turn off payload logging
19The JVM has no in-process sandboxSalesforce core; SFCC (lane C)Isolate services; scan dependencies; patch fast (JEP 411)
20Consent isn't carried with the eventGoogle Merchant (lanes C, E); Customer Privacy API (lane E)Attach Consent Mode v2 state at capture; drop when denied
21Retries duplicate writesGoogle Merchant (C, E); Azure (D); Worker (E)One idempotency key per event; duplicate detection; upsert on stable IDs
22Wasm host imports are the doorShopify Function (lane E)Grant the fewest imports possible
23Containers share the kernelDocker panelNon-root, dropped capabilities, seccomp; a VM for untrusted code
24Kubernetes Secrets are base64Kubernetes panel, etcdetcd encryption with a KMS provider, or an external vault
25Pod traffic is plaintext by defaultKubernetes panel, arrow between nodesDefault-deny NetworkPolicy; mTLS or WireGuard
26Secrets baked into imagesDocker panelBuild secrets; image scanning; inject at runtime
27The host can read VM memoryVirtual machine panelConfidential VMs (AMD SEV-SNP, Intel TDX); keys in an HSM or KMS
28The iPaaS keeps copiesCeligo (lane D)Mask fields in the flow; short retention; confirm region and DPA
29Integration credentials are too broadSAP S/4HANA (lane D)Least privilege per interface; managed identity; rotation
30Checkout JavaScript gets skimmedMagento (lane D)Patch; CSP and Subresource Integrity; PCI DSS 4.0 script inventory
31Batch files sit in plaintextSAP S/4HANA (lane D)PGP-encrypt; delete after pickup; prefer APIs or queues

Browser, machine and agent: three callers, one endpoint

The same Worker or Xano endpoint is called by three kinds of caller, and each one fails differently. A browser is a person whose code you can't trust. A machine is code with no person behind it. An agent is code choosing what to do for a person who isn't watching.

Browser vs Machine vs Agent. Three caller cards: a browser (a person and every script on the page, proving identity with a session cookie, consent as a banner choice, limited by same-origin policy, CSP, SameSite cookies and CORS); a machine (a service account with no person behind it, proving identity with an API key, HMAC or mTLS, carrying consent only as data, limited by scopes and network policy); and an agent (a model choosing tools for a user, proving identity with a delegated token and a signed mandate, limited by a tool allowlist, mandate check, budget and approval). Arrows meet at one endpoint that must ask which kind of caller it is. A table compares failure, replay, leaks, consent and audit. A band traces assumptions from a JVM that never leaves the VM through commerce, global and AI-legible stages. Seventeen lettered gaps have fixes.

Open the SVG full size (1600 × 2022) · Download the PDF · JPG. Gaps here are lettered: B for browser, M for machine, A for agent.

The endpoint's first question is which kind of caller is this? One rule for all three fails all three:

CallerThe endpoint requires
BrowserA user session, CSRF protection, and server-side authorization per record
MachineA verified signature or mTLS, a narrow scope, and an idempotency key
AgentThe user and the agent identified, a valid mandate, an allowed tool, and an audit entry
GapFix
B1 Client checks are suggestionsAuthorize every request on the server, per record
B2 Third-party tags see the pageCSP, Subresource Integrity, a script inventory (PCI DSS 6.4.3)
B3 Tokens readable by XSSHttpOnly, Secure, SameSite cookies; nothing in localStorage
B4 Tags fire before consentConsent Mode default denied; load tags after the choice
B5 Hydration ships dataPass only what the screen shows; fetch PII after consent
M1 Keys too broad, too longLeast-privilege scopes, short lifetimes, rotation
M2 No person in the loopAct as a named service, log it, cap what it can change
M3 Consent drops outCarry consent state as a field; refuse records without it
M4 Replays duplicate writesVerify the HMAC on the raw body; one idempotency key per event
M5 Payloads persistStore IDs, not payloads; purge queues; scrub logs
A1 Prompt injectionTreat fetched content as data; approve side effects outside the model
A2 Excessive agencyOne narrow tool per task; read and write as separate tools
A3 Token passthroughAudience-bound tokens per tool; never forward the user's token (MCP security best practices)
A4 Mandate replay or driftSign amount, merchant, expiry and nonce (AP2); check every call
A5 Exfiltration by toolEgress allowlist; no raw-URL tools; filter outputs for PII
A6 Retries double-spendAn idempotency key on every side-effecting tool call
A7 No record of whyLog user, agent, mandate, tool and inputs to an append-only ledger

Assumptions that break as you grow

A JVM that never leaves its VM can get away with assumptions that stop holding one stage at a time. Each stage keeps every problem from the stages before it.

AssumptionA JVM that never leaves the VMWhen you add commerceWhen you go globalWhen you add LLMs: AI-legible
Trust boundaryThe VM is the wall; inside is trustedPayments, tax and tag scripts run outside itClouds and regions multiply the wallsYour content is input to someone else's model
IdentityOne app, one user table, one session storeCustomers, guests, staff, payment tokensLocal logins and age rules per marketAn agent acts for a user: identify both
Where data livesIn the heap and one databaseCard data enters PCI scope; webhooks leaveResidency: where each copy lives is a legal questionPrompts, embeddings and model logs are new copies
ConsentNot modeled: the app owns its dataMarketing consent and order data divergeGDPR, CPRA, PIPA, CASL: the defaults differTraining and retrieval each need their own yes
Time and moneyOne clock, one currency, one localeRetries and refunds need idempotency keysCurrencies, tax, FX, time zones, languagesAn agent can spend: a mandate with amount and expiry
FailureAn exception stays in the logA failure can charge twice or lose an orderOne region down mustn't take the othersA plausible wrong answer looks like success
Audit asksWho logged in?Who paid, refunded or changed a price?Which law applied, and where did the data go?Which user, agent, mandate and source?
Readable byYour own teamAuditors, the PCI assessor, payment partnersRegulators in every marketCrawlers, answer engines and agents

The last column is the one teams plan least for. Making a system legible to AI — structured data, llms.txt, clean JSON-LD, Markdown descriptors — is how answer engines and agents find and recommend it. The same legibility makes it readable, quotable and steerable by every model that fetches it, so anything an agent can read is something an attacker can phrase as an instruction (A1).

Four runtimes, four resolution planes, one join

The platforms a commerce stack connects don't just run different languages. Each one decides who a customer is, what a product is, and what that customer agreed to on its own terms — a separate resolution plane.

PlatformRuntimeResolves people byID keyConsent model
SalesforceJava: Apex, SFCC's server JavaScript on Rhino in the JVM, MuleSoftData Cloud identity resolution rulesContact and Account IDsSalesforce consent objects
AdobeJava for AEM; PHP for Adobe Commerce (Magento)Experience Platform identity graphECID and identity namespacesConsent and governance labels
ShopifyRuby on Rails at the core; Functions as Wasm, written in Rust or JavaScript compiled by JavyCustomer accountsgid://shopify/Customer/…The Customer Privacy API
Google Merchant · AdsGoogle's own, not public; your side is the Python client, notebooks and BigQuery exportsHashed user data for enhanced conversionsofferId and GTINConsent Mode v2 consent states (ad_storage, analytics_storage, ad_user_data, ad_personalization)

Rust, Wasm and Javy are Shopify's extension layer — the code you write — not its core. Python is the client side of Google Merchant, not Google's runtime. And "Adobe is Java" holds for AEM but not for Adobe Commerce.

Four runtimes, four resolution planes, one join. Four cards — Salesforce, Adobe, Shopify, Google Merchant and Ads — each list a runtime, how it resolves people, its ID key and its consent model. Arrows labeled JSON over HTTPS meet at one join, your middleware, iPaaS or agent, which maps IDs, carries consent, propagates deletion and validates payloads. Six security gaps at the join are marked in red: consent drops in the mapping, IDs resolve to different people, deletion and opt-out don't propagate, the join keeps copies, payloads are trusted without the contract, and data moves to a region it shouldn't be in. A table states what crosses the boundary.

Open the SVG full size (1600 × 1200) · Download the PDF · JPG. Security gaps at the join are marked in red.

Only data crosses between planes, never runtimes. A JVM, a Ruby process, a Wasm module and Google's services never share memory; they exchange JSON over HTTPS. So memory safety protects within each plane and stops at its edge. Between planes, the security boundary is the data contract — a schema checked on every payload — and the join that enforces it.

The join is your code: middleware, an iPaaS flow or an agent. It is the only place the four planes meet, so it is where the gate from From cron to agents and the consent field belong — and where the gaps concentrate:

GapFix
J1 Consent drops in the mapping (gaps 20, M3)Carry consent state as a field on every mapped record; refuse records without it
J2 IDs resolve to different peopleKeep a mapping table with the source of each match; never merge on a guess
J3 Deletion and opt-out don't propagateFan every erasure and opt-out out to every plane, and log each plane's receipt
J4 The join keeps copies (gaps 18, 28)Store IDs and mappings, not payloads; purge on a schedule
J5 Payloads trusted without the contractValidate every inbound payload against the schema, whoever sent it
J6 Data moved to a region it shouldn't be inKnow each plane's regions; route and store by residency, not convenience

No plane learns about another plane's opt-out or erasure on its own. If a customer withdraws consent in Shopify, Salesforce, Adobe and Google still hold their own copy of "yes" until your join tells them otherwise — and keeps the receipt that it did.

Binding: owned events vs latent iPaaS, and the clean room for AI

A clean room, for AI-first automation, is a controlled place where data from more than one party is joined and analyzed while no party, model or agent sees another party's individual records. Agents learn from the combined data without ever receiving a row. Four rules make it one:

  1. Only consented records enter — a separate consent scope, checked on the way in.
  2. Only reviewed templates run — no free-form queries, from people or from agents.
  3. Only aggregates above a privacy threshold leave — never rows or identifiers.
  4. Every query is logged — who or what asked, which template, what came out.

Binding and the clean room for AI-first automation. A definition box states the four rules. Two ways to bind systems: an owned pipeline of a signed event, an edge check of the HMAC, idempotency key and consent, a durable queue of IDs and consent state, an idempotent processor and a ledger; and a latent iPaaS flow whose steps are marked as red gaps — scheduled flows minutes behind, mappings in a vendor UI outside code review, retry stores with full payload copies, and broad connector accounts. Three ways Shopify and Google data meet: enhanced conversions and Customer Match, marked in red as not a clean room because Google does the join; Ads Data Hub and BigQuery clean rooms, where only thresholded aggregates leave; and the CRM Sync clean room with templates Q1 to Q4. What an agent may learn: a question goes through a template into the clean room, through threshold and difference checks, and comes back as an aggregate. Eight red gaps have fixes.

Open the SVG full size (1600 × 1420) · Download the PDF · JPG. Security gaps are marked in red.

  1. The source emits a signed event — a Shopify webhook, or delivery through EventBridge or Pub/Sub.
  2. The edge checks it — the HMAC on the raw body, an idempotency key from the event ID, and the consent state before anything leaves (the egress gate).
  3. A durable queue carries IDs, minimal fields and the consent state — not whole payloads.
  4. An idempotent processor upserts by stable keys — offerId, Shopify global IDs, Salesforce record IDs — through each plane's API.
  5. A ledger records every mapping and every yes and no.

The rule underneath is reference, don't replicate: bind systems by ID and resolve details when they are needed. When you must copy, copy the minimum, and carry the consent state with it.

Owned event bindingLatent iPaaS flow, as MuleSoft and Celigo are often deployed
LatencySeconds, triggered by the eventOften scheduled or polling, minutes behind (both also support real-time listeners)
Where the mapping livesCode in Git, reviewed and testedThe vendor's UI, often outside code review
Copies of dataIDs and minimal fieldsRetry and error records keep full payloads (gap 28)
CredentialsOne narrow token per interfaceBroad connector accounts (gap 29)
ConsentA field the gate checks at send timeOften unmapped, or checked at the last run (J1)
Good forPersonal data, consent, agent actionsERP back office, batch reconciliation, low-sensitivity data

How Shopify and Google data meet

There is no joint Shopify–Google clean room product. Data meets in one of three ways, and only two of them are clean rooms:

PathHow identities are matchedWhat comes out
Enhanced conversions · Customer MatchYou send hashed email or phone; Google matches themGoogle sees the hashes and does the join. Not a clean room
Ads Data Hub · BigQuery data clean roomsShopify orders, exported to BigQuery, join Google ads data inside the roomAggregates only. Ads Data Hub filters rows under about 50 users (10 for clicks and conversions) and runs difference checks; BigQuery's aggregation-threshold rule sets your own minimum
CRM Sync clean roomSalted hashes in D1, a CleanRoomSession Durable Object, a clean_room consent scopeOnly templates run: Q1 attribution lift, Q2 segment overlap, Q3 UCP mandate impact, Q4 offline conversions back to Google

What an agent may learn

An agent asks a question, the question runs only as a reviewed template, the clean room joins consented records under salted hashes, threshold and difference checks drop anything too small or too overlapping, and the agent gets counts and rates back — never a row or an identifier.

GapFix
I1 iPaaS runs minutes behindEvent-driven for personal data; check consent at send time, not at the last run
I2 Mapping outside reviewExport mappings to Git; review a mapping change like a code change
I3 Payload copies in retry storesMask sensitive fields in the flow; short retention on error records
I4 Broad connector accountsOne narrow credential per interface; rotate on a schedule
C1 Free-form SQL from the agentTemplates only; a new question is a new, reviewed template (A2)
C2 Differencing rebuilds a personDifference checks, query budgets or added noise
C3 Revoked consent still in the roomRemove a record on revocation, before the next query runs
C4 Rows exported, not aggregatesOnly aggregates above the threshold leave; no row-level export

C2 is the AI-era risk. A person rarely runs hundreds of nearly identical queries; an agent can, and subtracting their results isolates one person. Ads Data Hub's difference checks exist for exactly this, and a homegrown clean room needs the same protection.

Localized AI: crawler files, locale-aware retrieval and LoRA adapters chosen by consent

robots.txt and llms.txt can't carry consent. They are public, site-wide files that tell crawlers which pages to fetch or read first. They hold nothing about any person, and Google's ad and audience systems never look in them for consent. A localized AI stack needs two channels kept apart: one decides which content AI may read, the other decides which people your systems may use.

Localized AI: crawler files versus consent, locale-aware retrieval, and LoRA adapters chosen by consent. Two channels that never mix: in the content channel, public pages pass robots.txt and llms.txt, marked as red gaps because they are public requests rather than access control, to Googlebot, Google-Extended, Storebot-Google and AI tools; in the consent channel, the Shopify Customer Privacy API feeds an edge that attaches the consent state to every event before Google receives Consent Mode v2 states and Customer Match consent fields. Red gaps: customer data in JSON-LD, and crawl permission read as consent. Localized retrieval in five steps, with the locale taken from the user's choice, then the Shopify market, then Accept-Language, never the IP address alone. LoRA adapters: a request goes to an adapter picker; no consent gives the language adapter, preferences add stated preferences, preferences and marketing unlock a personalization adapter, all run on Workers AI with a ledger entry. Training a personalization adapter: records consented for training, the clean room, training, a registry and scheduled retraining. Rules for the EU and UK, the US, South Korea and Japan, and ten red gaps with fixes.

Open the SVG full size (1600 × 1490) · Download the PDF · JPG. Security gaps are marked in red.

Content channel (crawlers)Consent channel (people)
What movesProduct pages, structured data, articlesEvents, conversions, hashed emails, audience lists
Controlled byrobots.txt, meta robots and nosnippet, llms.txt (a hint only)Consent Mode v2 states, Google Ads API consent fields, the Shopify Customer Privacy API
ScopeThe whole site, for each botEach person, each event
Enforced byThe bot's good behavior: a request, not access controlGoogle's processing rules, and your own server before data leaves

The content channel, bot by bot

The Merchant API carries products, never people, so it needs no consent state. Shopper data reaches Google only through tags that carry ad_storage, analytics_storage, ad_user_data and ad_personalization, and through Customer Match and enhanced-conversion uploads with their own ad_user_data and ad_personalization consent fields. A shopper's consent state has to be attached to every event at the edge before anything leaves (gap 20).

Loading localized content for retrieval (LangChain)

LangChain doesn't know about locales. Localization works when every document carries its locale in metadata, and retrieval filters by locale first, then falls back to another language.

SourceHow to fetch each locale
Webflow CMSGET /v2/collections/{id}/items/live?cmsLocaleId=…, once per locale
ShopifyStorefront API with @inContext(language: KO, country: KR)
KB and site pagesSitemap hreflang alternates; one llms.txt for each locale, from an allowlist
Machine translationThe translation proxy, with the document marked as machine-translated
new Document({
  pageContent: item.text,
  metadata: {
    locale: "ko", market: "KR",
    canonical_id: item.baseId,          // same ID for every translation of this item
    source: "webflow",
    translation: "human",               // or "machine"
    url: item.localizedUrl,             // cite the localized page
  },
});

Embed every locale in one index with a multilingual model (Workers AI @cf/baai/bge-m3), and create Vectorize metadata indexes on locale and market so they can be filtered. Retrieve in the user's locale and market first. When too few chunks match, add English chunks whose canonical_id has no native translation, and have the model answer in the user's language, citing the localized URL. Detect the locale from the user's explicit choice, then the Shopify market, then Accept-Language. Never from the IP alone.

A LoRA adapter is a small set of weights loaded on top of a base model at answer time. Choose it by locale, market and the user's consent state, and allow it to be trained only on data whose consent covers training. There are two consent questions: may these records train the adapter, and may this user's request use it right now.

AdapterTrained onConsent to trainConsent to use
Language (lang-ko, lang-ja)Public catalog, KB, human-reviewed translations, brand termsNone: no personal dataNone
Personalization (persona-us-loyal)Pseudonymized behavior from customers who consented to trainingYes, for every recordYes, on every request

The language adapter is the default. A personalization adapter is a privilege that consent turns on. Each adapter has a registry entry that records its consent basis:

{
  "id": "persona-us-loyal-v3",
  "base_model": "@cf/mistral/mistral-7b-instruct-v0.2-lora",
  "locale": "en", "market": "US", "purpose": "personalization",
  "consent_required": ["preferences", "marketing"],
  "dataset_hash": "sha256:…", "record_count": 18421, "min_cohort": 50,
  "trained_at": "2026-10-01", "retire_by": "2026-10-31"
}
function pickAdapter(reg, locale, market, consent, now = Date.now()) {
  const live = reg.filter(a => Date.parse(a.retire_by) > now);
  const persona = live.find(a =>
    a.purpose === "personalization" && a.locale === locale && a.market === market &&
    a.consent_required.every(k => consent[k]) && !BLOCKED_MARKETS.has(market));
  return persona ?? live.find(a => a.purpose === "language" && a.locale === locale) ?? null;
}

Consent comes from the Shopify Customer Privacy API (preferencesProcessingAllowed(), marketingAllowed(), analyticsProcessingAllowed(), saleOfDataAllowed()), stored on the session at the edge. The Worker calls env.AI.run(model, { messages, lora: adapter.id }) and writes the adapter, locale, market and consent state to the ledger. Workers AI accepts LoRA only on its -lora base models; wrap the call in a RunnableLambda if the LangChain chat wrapper doesn't pass lora through.

Consent stateAdapterContext sent to the model
None, or deniedLanguageThe query and public retrieved chunks only
PreferencesLanguagePlus stated preferences such as size or language; no history
Preferences and marketingPersonalizationPlus a cohort label and recent product views; never raw personal data

Rules by locale

MarketPersonalizationTraining on behavior
EU and UK (GDPR, ePrivacy)Opt-inIts own purpose consent, not just "marketing"
US (CCPA and CPRA)Respect saleOfDataAllowed() and Global Privacy ControlOnly records that haven't opted out
South Korea (PIPA)Separate consent for use beyond the original purpose and for overseas transferLanguage adapter only until in-country storage is live
Japan (APPI)Pseudonymized data allowed for internal analysisCheck transfer consent before training outside Japan

Training runs inside the clean room: only records consented for training, pseudonymized, with cohorts below min_cohort dropped. You can't remove one person from trained weights, so retire_by forces a retrain without the people who withdrew, and the privacy notice says so. Adapters never go to Google: Google Ads audience targeting still runs only through consent states and Customer Match consent fields.

GapFix
L1 robots.txt used as access controlProtect sensitive paths with authentication. A Disallow line publishes the path
L2 llms.txt lists private pagesGenerate it from an allowlist of public URLs, never from the whole CMS
L3 Crawl permission read as consentContent opt-ins and data consent are separate channels; check consent states on every event
L4 Structured data leaks peopleNo customer names or order details in JSON-LD; it is published with no consent check
L5 Stale price from the vector storeNever answer price or availability from embeddings; fetch live with @inContext(country:)
L6 Machine-translated claims in answersOnly translation: "human" chunks for legal and claims text
L7 Locale guessed from IPExplicit choice, then market, then Accept-Language; apply that locale's rules
L8 Adapter trained without training consentA separate training scope, recorded per record; dataset_hash and record_count in the ledger
L9 Withdrawn consent stays in the weightsScheduled retirement (retire_by) and a retrain without the withdrawn
L10 Personalization served without a consent checkCheck consent and blocked markets on every request; fall back to the language adapter

L3 is the confusion at the heart of this section. Allowing a crawler is a decision about content. It never grants consent to use a person's data, and an agent acting for a user may not read either file at all.

Audience targets in a CMS: definitions, not members

An AI-enabled audience collection in Webflow stores definitions, never members. It holds no emails, hashes or customer IDs. Membership is resolved at sync time in Shopify, Klaviyo or BigQuery, checked against each person's state, and sent to Google with consent fields attached. An agent may propose an audience as a draft item; a person approves it by publishing.

Audience targets in a CMS: definitions, not members. Lifecycle in five steps: an agent proposes a draft through the Data API with its rationale and eval result, a person reviews it and the definition hash is recorded, the item is published as Live, the sync reads only Live items and refuses a hash mismatch or an item marked as containing personal data, and destinations receive members with ad_user_data and ad_personalization. Where each piece lives: the Webflow collection holds the definition, the targeting state, the AI fields and the approval; Xano and the ledger hold each person's consent, DSR, device class, coarse location and campaigns; the sync joins them and writes only totals back. The sync checks each matched person in order, DSR, consent, location, device, campaign and cohort, and stops at the first failure. An example Audience States table shows one synced slice, one blocked by market and one held below the minimum cohort with every count left empty. Eight red gaps, T1 to T8, have fixes.

Open the SVG full size (1600 × 1430) · Download the PDF · JPG. Security gaps are marked in red.

Each audience carries targeting conditions (consent, DSR, device, location and campaign rules) that a person must meet. Each person's own state stays in Xano and the ledger. Webflow gets back only aggregate state snapshots.

Audience Targets

FieldWebflow typePurpose
Name, SlugPlain text, Slug"US loyal buyers, 3+ orders"
StatusOption: Proposed by agent · In review · Approved · Live · Paused · RetiredOnly Live syncs
DescriptionPlain textPlain-language meaning, for people
Definition formatOption: Shopify segment query · Klaviyo segment · BigQuery template · pLTV thresholdHow to read the definition
DefinitionPlain text, multi-lineThe query that defines the audience, not a snapshot of its members
Definition hashPlain textSHA-256 of the definition; any change needs re-approval
Source systemOption: Shopify · Klaviyo · BigQuery · XanoWhere membership is resolved
MarketsMulti-reference → MarketsWhere the audience may be used
Consent requiredMulti-reference → Consent PurposesPurposes each member must have granted
Consent stateOption: All required granted · Any required granted · Not required (language only)Personalization and ad destinations use All required granted
DSR ruleOption: Exclude any open DSR · Exclude listed DSR typesDefault: exclude any open DSR
DSR types excludedMulti-reference → DSR TypesUsed with Exclude listed DSR types
DevicesMulti-reference → Device ClassesDevice classes the audience may reach
Device signalOption: User-agent class · App SDK · NoneNever device IDs or fingerprints
Location granularityOption: Country · Region or state · NoneCoarse only: no city, postcode or coordinates
Location sourceOption: Shopify market · Billing country · IP countryIP country never sets the locale or the legal rules (L7)
CampaignsMulti-reference → CampaignsCampaigns allowed to use this audience
DestinationsMulti-reference → DestinationsGoogle Ads Customer Match, Klaviyo, the on-site assistant
LoRA adapterReference → AdaptersThe personalization adapter for this cohort, if any
Min cohortNumberDon't sync or report below this size
Retire byDate/timeThe definition must be reviewed again by this date
Contains personal dataSwitchMust be off; the sync refuses items with it on
AI: Proposed by · Rationale · Eval resultPlain text · Rich text · Option: PASS, FAIL, REVIEWThe agent's proposal, reasoning and rubric result
AI: Assistant summary · Embedding textPlain textWhat the assistant may say, and the public text indexed for RAG
Approved by · Approved at · Ledger entryPlain text · Date/time · LinkWho approved it, when, and the ledger record

Supporting collections

CollectionFields
MarketsName, Country code, Regime (GDPR · UK GDPR · CCPA/CPRA · PIPA · APPI), Personalization allowed, Training allowed, Hold note
Consent PurposesName, Key (ad_user_data, ad_personalization, marketing, preferences, training), Google consent field, Shopify API method
DestinationsName, Platform, Required consent fields, Credential reference: the name of the secret in Cloudflare or Xano, never the secret
AdaptersAdapter ID, Base model, Locale, Market, Purpose (Language · Personalization), Dataset hash, Record count, Trained at, Retire by
DSR TypesName, Key (access · deletion · opt_out_sale_share · correction · restrict · object), Excludes from targeting, Regimes, Deadline days (30 GDPR, 45 CCPA, 10 PIPA)
Device ClassesName, Key (desktop · mobile_web · ios_app · android_app · ctv · agent_api), Consent source, Personalization allowed
CampaignsName, Platform, External campaign ID, Objective, Markets, Consent required, Start, End, Status, Ledger entry

agent_api is its own device class: an AI agent shopping for a user gets no persona adapter unless the user's mandate covers it.

Audience States: aggregate snapshots, written by the sync

One item per audience, campaign, market and device class for each sync: the State (Synced · Held below cohort · Blocked by consent · Blocked by DSR · Blocked by market · Error), Matched by definition, Excluded: open DSR, Excluded: consent not granted, Excluded: blocked market or location, Excluded: device not allowed, Sent to destination, Consent coverage (percent), Snapshot at and Ledger entry. Every count below the audience's Min cohort is left empty: small counts sliced by device, market and campaign can identify a person (C2).

Per-person state, outside the CMS

{
  "subject": "hmac:7e1a…",
  "consent": { "ad_user_data": true, "ad_personalization": true, "marketing": true, "preferences": true, "training": false, "source": "shopify_customer_privacy", "at": "2026-10-12T09:14:00Z" },
  "dsr": { "open": null, "last": { "type": "access", "closed_at": "2026-08-02" } },
  "device_class": "mobile_web",
  "location": { "country": "US", "region": "CA", "source": "shopify_market" },
  "campaigns": ["spring-sale"]
}

For each person the definition matches, the sync checks in this order and stops at the first failure:

  1. DSR: an open request, or one of the excluded types, excludes the person. Deletion and opt-out win over everything else.
  2. Consent: every purpose in Consent required, and the destination's required consent fields, must be granted.
  3. Location: the person's market must be listed and not blocked; that market's regime rules apply.
  4. Device: the device class must be listed, and allow personalization if a LoRA adapter is attached.
  5. Campaign: the campaign must be Live, in its dates, with its consent requirements met.
  6. Cohort: below Min cohort, the state is Held below cohort and nothing is sent.

Customer Match uploads carry ad_user_data and ad_personalization. The ledger keeps each person's decision; Webflow gets only totals. The agent creates items through the Data API (POST /v2/collections/{id}/items) with isDraft: true and can't publish. The sync reads only published Live items and refuses any item whose definition hash doesn't match the approved one.

GapFix
T1 Members stored in the CMSDefinitions only; Contains personal data must be off, and the sync refuses the item otherwise
T2 A template page publishes the collectionDon't bind it to a Collection template page; every field on one is public
T3 The agent publishes its own audienceAgents create drafts only; a person reviews and publishes
T4 Definition changed after approvalA definition hash recorded at approval; a mismatch blocks the sync
T5 Small slices identify peopleCounts below Min cohort left empty in every snapshot
T6 Open DSR still targetedDSR checked first, before consent, on every sync
T7 Precise location or device IDsCountry or region and a device class only
T8 Secrets in the Destinations collectionStore the secret's name; the secret lives in Cloudflare or Xano

VM, container or managed database — and the storage wrappers

Where the database runs decides who patches it, where its data sits, and which wall you are relying on. In every case the app points to the database: connections start at the app, and the database never connects back to it.

Hardware (cloud host)
└─ Hypervisor                       ← the wall between VMs
   └─ VM: guest OS (its own kernel)
      └─ Database process
         └─ Data files ─── on an attached virtual disk (EBS, Persistent Disk, Managed Disks)
VMContainerManaged database
What it holdsIts own OS kernel, plus whatever you installThe app process and its libraries; it shares the host's kernelYou never see the machine; you get an address or an API
Where the data livesOn an attached virtual disk you can detach, snapshot and reattachOn a mounted persistent volume, or it disappears with the containerProvider storage, with backups and replicas included
Who patches the OS and engineYouYou patch the image; the platform patches the nodeThe provider
The wallThe hypervisor (gap 27)One shared kernel (gap 23)The provider's tenancy, plus your access settings
What you still secureDisk and snapshot encryption, the database port, the OSImage secrets (gap 26), pod traffic (gap 25), Secrets (gap 24)Who can reach it, with which credential, and whether anything is public

A managed service moves the operating system and the patching to the provider. It does not move the access model. Keys, IAM bindings, bucket policies and public toggles stay yours — that is the shared responsibility line, and almost every public data leak sits on your side of it.

Container, Docker, Kubernetes, and a container in the cloud

Four words, four different layers. They are easy to blur because they usually appear together.

LayerWhat it isAnalogy
ContainerThe thing itself: an app and its libraries packaged as an OCI image, run as an isolated process that shares the host's kernelA shipping container
DockerA tool that builds images from a Dockerfile and runs containers on one machineThe crane and packing crew at one dock
KubernetesAn orchestrator: runs many containers across many machines, restarts and scales them, networks them, and stores their config and Secrets. It doesn't build imagesThe port's scheduling system across every dock
A container in the cloudSomeone else runs the machines. You hand over an imageRenting space in a port someone else operates

The same image runs under all of them. Build it once with Docker, and it can run on a laptop, in a Kubernetes cluster or on a cloud service unchanged.

"A container in the cloud" comes in three amounts of control:

You wantUseYou manageThe provider manages
Kubernetes, without running its control planeManaged Kubernetes: AWS EKS, Google GKE, Azure AKSYour Kubernetes objects, node settings, network policy, SecretsThe control plane, and usually node patching
Just run this image, no clusterServerless containers: AWS Fargate and App Runner, Google Cloud Run, Azure Container AppsThe image, its settings, who may call itMachines, scaling, patching
A container next to a WorkerCloudflare Containers, started and addressed from a WorkerThe image and the Worker that fronts itMachines, placement, scaling

A Cloudflare Worker is not a container. It is a V8 isolate: lighter, starting in milliseconds, with no filesystem and no kernel of its own. That is why the Worker sits in front and a container is reached only through it, when you need a full Linux process.

Security follows the layer, not the brand. The image carries its own risks wherever it runs (secrets baked into layers, gap 26). Kubernetes adds cluster risks (Secrets as base64, gap 24; plaintext pod traffic, gap 25). A serverless container service removes the cluster risks but keeps the image risks and your access settings.

Kubernetes Secrets and SVG bundles: the same wrapper

A Kubernetes Secret and an SVG that embeds images as data: URIs use base64 for the same reason: to carry bytes inside a text format (YAML for one, XML for the other). In both, anyone who gets the text can decode it in one line, and a scanner that only reads text sees nothing but the encoded string.

Kubernetes Secret vs SVG bundle. Left: Secret YAML with a base64 password is stored in etcd as base64 unless encryption at rest is on, read through the API server where RBAC decides who may get or list it, and mounted into a pod decoded; committed to Git, the base64 stays in history. Right: an SVG embeds patient metadata and a base64 nested SVG carrying an onload handler; the browser decodes it and either draws it through img with no script, or, inlined, runs the outer file's script on your origin; anyone with the file decodes every embedded byte. A table compares the two, and three cards give the shared lesson.

Open the SVG full size (1600 × 960) · Download the PDF · JPG.

They differ in one way that matters: only the Kubernetes Secret can be locked. The cluster puts RBAC in front of it and can encrypt it in etcd with a KMS provider. An SVG has no lock to turn on — once the file is shipped, its bytes are its contents. And only the SVG can execute: an inlined outer SVG runs its script, while a nested SVG loaded through <img> or <image> is drawn as a picture.

The shared lesson:

The wrappers, by provider

ServiceWhat it isHow you reach itPublic by default?Encryption at restClassic foot gun
Cloudflare D1Serverless SQLiteA Worker binding; a REST API with an account tokenNoYesAn account-wide API token used where a binding would do
Cloudflare KVGlobal key-value, cached at the edgeA Worker bindingNoYesEventually consistent: wrong for idempotency or counters (gap 21)
Cloudflare R2S3-compatible object storageA Worker binding, S3 API keys, presigned URLsNo, until you enable public accessYesPublic access is per bucket: one bucket mixing public and private files exposes both
Durable ObjectsA single, strongly consistent instance with its own storageOnly through a WorkerNoYesPutting a per-user object's ID somewhere a stranger can guess it
HyperdriveA connection pool to your existing Postgres or MySQLA Worker binding—Credentials held by CloudflareForgetting the database behind it still needs TLS and its own allowlist
AWS S3Object storageIAM, bucket policies, presigned URLsNo: Block Public Access is on for new bucketsYes, by defaultA bucket policy with "Principal": "*"
AWS RDSManaged Postgres, MySQL, SQL Server and othersA host name and a database user, or IAM authOnly if "publicly accessible" is setChosen at creation; can't be switched on later in place"Publicly accessible" plus an open security group
AWS DynamoDBManaged key-value and documentIAMNoYes, by defaultIAM policies with dynamodb:* on *
GCP Cloud StorageObject storageIAM, signed URLsNo, with public access preventionYes, by defaultGranting allAuthenticatedUsers: that means any Google account, not your users
GCP Cloud SQLManaged Postgres, MySQL, SQL ServerPrivate IP, the Cloud SQL Auth Proxy, or IAM authOnly with a public IP and authorized networksYes, by defaultAuthorizing 0.0.0.0/0 to reach a public IP
GCP Firestore / BigQueryManaged document store / warehouseIAMNoYes, by defaultA dataset shared too broadly, or exported to a public bucket
Azure Blob StorageObject storageEntra ID roles, account keys, SAS tokensNo: anonymous access is off by default on new accountsYes, by defaultAccount keys grant everything; long-lived SAS tokens leak
Azure SQL / Cosmos DBManaged relational / multi-modelEntra ID or keys, private endpointsDepends on network settingsYes, by defaultA primary key embedded in an app instead of Entra ID
XanoA managed backend: database plus APIXano's API with a token; never the database portNoManaged by XanoAn endpoint left without auth or per-record checks (gap 4)

"Encrypted at rest by default" means the provider's disks are encrypted. It does not mean your data is hidden from anyone who holds a valid key, token or IAM role — and those are what leak. For PII, card data and PHI, keep the access model narrow, prefer identity-based access over long-lived keys, and encrypt sensitive fields yourself where the provider would otherwise see them.

Known foot guns

A foot gun is a feature, API or default that makes it unusually easy to shoot yourself in the foot: a perfectly natural-looking use causes the bug, outage or leak. The blame lands on the design, because the design invites the mistake. The remedy is the same every time: make the safe path the default, and make the dangerous path explicit and loud — the way Rust spells it unsafe and React spells it dangerouslySetInnerHTML.

Foot gunWhat happensInstead
el.innerHTML = svgFromCms (or `{{ html \safe }}`)An uploaded SVG's <script> runs on your origin (gap 3)<img src>, or sanitize first (Safe SVG)
atob() called "decrypt"Anyone reverses it; the secret was never hidden (gap 1)Real encryption, or a token that means nothing on its own
Cloudflare SSL mode FlexiblePlain HTTP from the edge to your origin (gap 5)Full (strict)
Reflecting the request's Origin with Access-Control-Allow-Credentials: trueAny site can make credentialed reads of your API. (Browsers already refuse * with credentials; echoing the origin bypasses that.)An explicit origin allowlist; CORS is not authorization (gap 4)
A secret in a NEXT_PUBLIC_ variableCompiled into every visitor's JavaScript (gap 13)Server-only variables; scan the bundle in CI
A Kubernetes Secret YAML in Gitbase64 in history forever (gap 24)Sealed or external secrets; etcd encryption
docker build --build-arg API_KEY=…The key lives in the image history (gap 26)docker build --secret
console.log(req.body) in a Worker, or body logging in XanoEvery payload copied into logs (gap 6)Log IDs and outcomes, never bodies
Verifying a webhook HMAC on re-serialized JSONValid webhooks fail, so someone turns verification off (M4)Verify on the raw body, then parse
Workers KV as the idempotency storeEventually consistent: two retries both see "not seen yet" (gap 21)A Durable Object or a database unique constraint
#[derive(Debug, Serialize)] on a struct holding PIIPII prints in logs and ships in JSONA Pii<T> wrapper with a redacting Debug and no Serialize
unsafe in a file parser "for speed"One malformed upload corrupts memorySafe Rust; measure before reaching for unsafe
Rendering an uploaded SVG with no size, depth or time limitMemory-safe code still exhausts the serverCaps on canvas size, nesting, element count and render time
Passing an SVG's raw markup to a modelHidden text the reviewer never saw becomes an instructionNormalize, extract only visible text, hand it over as untrusted data
An Elm port that sends the whole model to JavaScriptEvery field crosses into code Elm can't protectPorts that carry only the fields the JS needs
Consent Mode with defaults left at grantedTags collect before the visitor chooses (B4)Default denied; update after the choice
An agent tool that fetches any URL it is givenPrompt injection turns it into an exfiltration channel (A5)An allowlisted fetch, or no fetch tool at all
Forwarding the user's OAuth token to a tool or MCP serverToken passthrough: the tool can act as the user anywhere (A3)A token issued for that tool's audience only
A service account with SAP_ALL or full Shopify Admin scopesOne leaked key is the whole ERP or store (gap 29)One narrow credential per interface
One R2, S3 or Blob bucket for both public and private filesMaking the public files public exposes the private ones tooOne bucket per audience
Granting allAuthenticatedUsers on GCPAny Google account on the internet can read itName your own users, groups or service accounts
An Azure storage account key in app configFull control of the whole account if it leaksEntra ID roles and managed identity; disable shared-key auth
RDS "publicly accessible" with a wide security groupThe database port answers the internetPrivate subnets; reach it through the app or a proxy