
The DifyTap Cross-Tenant Bug: Writing Safer Multi-Tenant AI Code with Node.js
What the DifyTap reporting says, and what is still unconfirmed
The public reporting points to a cross-tenant flaw in a Dify-related AI stack that may have let one tenant intercept another tenant’s AI data. That is the part worth taking seriously, and this post breaks down what that means for developers writing safer multi-tenant Node.js code.
I would not treat every number in the story as equally solid. The “1M+ apps impacted” figure is reported context, not something I can verify from the material I have here. Until the methodology is public, I would read that as a headline number, not a benchmark.
The public claim: cross-tenant AI data could be intercepted
The core allegation is straightforward: tenant boundaries were weak enough that data intended for one customer could become visible to another. In an AI system, that is not just a privacy issue. It can expose:
- prompts and system prompts
- retrieval results from private knowledge bases
- tool-call arguments and outputs
- conversation history
- traces and debugging metadata
- files or artifacts attached to runs
That is the real risk. If the stack leaks any of those across tenants, the attacker does not need to “break AI.” They only need to find a backend object that was never scoped properly.
Why I would treat the app-count number as reported context, not a verified benchmark
The reported “1M+ apps impacted” claim may well be accurate, but I would not base an engineering conclusion on it without the underlying data.
What defenders need to look at is the pattern:
- a shared multi-tenant AI platform
- a tenant isolation failure
- a data path that allowed cross-tenant visibility
- enough blast radius to make the issue operationally severe
If your architecture looks like that, the exact number is almost secondary.
The boundary between confirmed facts and inference
What I would treat as confirmed from the report:
- the issue is cross-tenant in nature
- the data involved is AI-related
- the platform is multi-tenant
- the impact is described as wiretapping or interception across tenant boundaries
What I would treat as inference:
- the exact root cause
- whether the leak was in traces, storage, queue handling, or an API authorization gap
- the exact affected version range
- the exact number of deployed apps affected
That split matters because the fix changes depending on where the boundary failed. But the defensive posture does not: never assume a shared layer will preserve tenant isolation on its own.
Why cross-tenant leakage in AI systems is a high-severity design bug
Tenant isolation is not just auth on the UI
A lot of teams think tenant isolation means “the dashboard only shows your own stuff.” That is too shallow.
Real isolation has to hold at every layer:
- browser UI
- API authorization
- database reads
- object storage access
- queue messages
- logs and traces
- caches
- background workers
- analytics exports
If any one of those layers can be queried without a tenant check, the UI is just decoration.
The hard part in AI systems is that the valuable data is often not the final answer. It is everything around the answer: prompt templates, retrieved context, chain-of-thought-like artifacts if you are logging too much, tool payloads, and run traces.
Where AI stacks leak data in practice: prompts, traces, logs, queues, and caches
This is where I usually look first.
| Layer | What leaks | Typical mistake |
|---|---|---|
| Prompt store | system prompts, user prompts, templates | missing tenant filter on read |
| Trace store | run metadata, tool calls, timings | shared trace IDs or global lookup |
| Logs | tokens, headers, user content | logging before redaction |
| Queues | job payloads, artifact refs | worker consumes messages without auth context |
| Cache | retrieval results, session state | same cache key across tenants |
| Object storage | files, exports, embeddings | predictable paths or shared prefixes |
The bug class is not “AI hallucinated something.” It is “the platform stored private data in a place that another tenant could later read.”
Why “shared infrastructure” is not the same as “shared data”
Using shared Redis, shared Postgres, shared S3, or shared workers is not automatically wrong.
What is wrong is shared infrastructure without enforced separation.
You can share hardware and still isolate data if you handle the boring parts correctly:
- tenant-scoped keys
- row-level access checks
- object-level authorization
- per-tenant namespaces
- per-tenant encryption boundaries where it matters
- authenticated context passed through every async hop
AI platforms often miss this because observability and convenience get added faster than data boundaries. Developers want to inspect runs, traces, and tool calls. If those views are not tenant-scoped from the start, the leak is usually one query away.
How a Node.js multi-tenant app usually gets this wrong
Losing tenant context across async handlers and background jobs
Node.js makes it easy to pass a request around without a clean security boundary. That is convenient, and dangerous.
A common failure mode looks like this:
app.post("/runs", async (req, res) => {
const run = await createRun(req.body);
await queue.add("process-run", { runId: run.id });
res.json(run);
});
The code “works,” but the background job now has no tenant context. If the worker later loads the run by runId alone, it may fetch the wrong customer’s data if IDs are guessable, reused, or looked up through a shared index.
A safer approach is to carry tenant identity in the job payload and require it on every read.
Reusing cache keys, queue names, or object paths across customers
Another very common bug is namespace reuse.
Bad patterns:
- cache key:
session:${sessionId} - queue name:
runs - object path:
exports/${reportId}.json
Better patterns:
- cache key:
tenant:${tenantId}:session:${sessionId} - queue name:
runs:${tenantId}or at least a tenant field in the payload - object path:
tenant/${tenantId}/exports/${reportId}.json
Even then, prefixing alone is not enough. A tenant prefix helps, but the read path still needs to verify the caller is allowed to access that tenant.
Trusting client-supplied tenant IDs instead of deriving them from identity
This is the mistake I see most often.
Bad:
app.get("/api/runs/:id", async (req, res) => {
const { tenantId } = req.query;
const run = await runsRepo.findByIdAndTenant(req.params.id, tenantId);
res.json(run);
});
If the client can send the tenant ID, the client can lie.
Better: derive tenant identity from the authenticated principal, not from request parameters.
function attachContext(req, res, next) {
req.ctx = {
userId: req.user.sub,
tenantId: req.user.tenantId,
roles: req.user.roles ?? []
};
next();
}
Once you do that, every service call should consume req.ctx, not raw request input.
A safer request flow for multi-tenant AI code in Node.js
Put tenant identity in one authenticated server-side context object
This is the simplest pattern that reliably holds up.
// auth-context.js
export function buildContext(req) {
return {
userId: req.user.sub,
tenantId: req.user.tenantId,
role: req.user.role ?? "member",
requestId: req.id
};
}
The goal is to make tenant identity boring and unavoidable. If you have to thread tenantId through fifteen call sites by hand, someone will forget it in the one place that matters.
Enforce authorization in the repository or service layer, not only in routes
Route guards are useful, but they are not enough.
// run-service.js
export async function getRun(ctx, runId) {
const run = await runsRepo.findOne({
where: { id: runId, tenantId: ctx.tenantId }
});
if (!run) return null;
return run;
}
The repository layer should include the tenant filter by default. If a developer forgets it at the route level, the service layer still protects the read.
That pattern matters for AI systems because the same run may be read from the API, the worker, the admin console, and the export path. You want the same authorization rule everywhere.
Scope every persisted record, blob, and log line by tenant
Every persistent object should carry tenant identity in a way the system can enforce, not just display.
Examples:
- database rows:
tenantIdcolumn plus enforced filter - files:
tenant/<tenantId>/... - logs: structured fields with
tenantId - traces: tenant-scoped trace IDs or index keys
- cache: tenant-prefixed keyspace
For logs, the rule is not “log less and hope.” It is “redact first, then log.” Secrets, prompts, and headers should be scrubbed before they hit observability.
Keep prompt, tool, and retrieval data isolated by default
If your stack has RAG, tool calls, or prompt chaining, isolation has to extend into those subsystems.
Do not:
- reuse a global vector index without tenant filtering
- store shared tool outputs in one bucket without tenant prefixes
- build prompt history lookups from bare conversation IDs
- let one tenant’s model traces appear in another tenant’s admin view
A good default is to make the unsafe path impossible. If a tenant filter is required to query anything, the query helper should refuse to run without it.
Practical checks I would run on a Dify-like stack
Test two accounts and try to fetch traces, runs, and artifacts across tenants
I would set up two test tenants, A and B, and try to read A’s artifacts from B’s session.
Useful checks:
curl -s -H "Authorization: Bearer $TOKEN_B" \
https://app.example/api/runs/$RUN_A | jq .
curl -s -H "Authorization: Bearer $TOKEN_B" \
https://app.example/api/traces/$TRACE_A | jq .
What I would want to see on a safe system:
404 Not Found, or403 Forbidden, or- an empty result set
What would worry me:
200 OKwith masked content but visible metadata403on the body, but the response still leaks title, owner, or timestamps- a redirect to a shared object URL that remains readable
Inspect Redis, job queues, and storage prefixes for shared identifiers
I would check whether tenant IDs appear in key names and message payloads.
Examples:
redis-cli --scan --pattern '*:run:*'
redis-cli --scan --pattern '*:tenant:*'
Things to look for:
- keys without tenant prefixes
- one queue for every customer
- job payloads that omit tenant identity
- storage paths that can be guessed from public identifiers
If your queue message does not carry the tenant, the worker has to recover it from somewhere else. That “somewhere else” is often the bug.
Look for responses that leak metadata even when content is blocked
Sometimes the content is protected but the metadata is enough to map the system.
Example failure mode:
{
"error": "forbidden",
"runId": "run_123",
"tenantId": "acme",
"model": "gpt-4.1",
"createdAt": "2026-06-23T13:02:11Z"
}
That is still data exposure. It may not reveal the prompt, but it confirms object existence, tenant naming, model usage, and timing.
Compare what the API returns with what the UI hides
The UI is often stricter than the API because product code hides fields for display reasons. The API may still return them.
That mismatch is where I would spend time. If the UI hides a record but the API still serves it by ID, you do not have an app-level security model. You have a presentation filter.
Defensive patterns that actually reduce blast radius
Row-level and object-level access checks on every read path
This is non-negotiable.
Read paths need checks, not just writes. A lot of teams secure create/update flows and forget the “get by id” path because it looks harmless.
If I had to pick one defensive rule, it would be this: every read must be scoped by tenant before the object is loaded, not after.
Separate encryption keys, buckets, and cache namespaces per tenant
If the platform is serious about multi-tenancy, separate trust domains where it matters.
Good defenses:
- distinct encryption keys or envelope keys per tenant class
- distinct object-store prefixes with access policy enforcement
- distinct cache namespaces
- distinct admin views and audit logs
You do not need a separate cluster for every tenant. But you do need a separation model that survives developer mistakes.
Redact secrets and prompts before they hit observability tooling
AI systems produce very “loggable” data, which is exactly why they get people in trouble.
I would redact:
- API keys
- auth headers
- session cookies
- private prompts
- tool arguments that include user data
- file contents
- embedding inputs if they are sensitive
If a trace UI shows too much, assume someone will eventually export it.
Add tests that fail when tenant isolation is bypassed
This is the easiest long-term win.
A useful regression test should:
- create tenant A and tenant B
- create a record under A
- authenticate as B
- attempt to read A’s record
- assert
403or404 - repeat for API, worker, trace, and blob paths
That test suite should cover:
- direct API access
- replayed job messages
- raw storage object access
- exported artifacts
- admin/debug views
If a future refactor removes a tenant filter, the test should fail immediately.
What I would fix first in a real production system
Lock down backend authorization before changing the frontend
I would not start with the UI. I would start with the read paths.
The first fixes I would make:
- enforce tenant checks in repository methods
- require tenant-scoped job payloads
- audit storage and cache keys
- redact logs and traces
- block cross-tenant object lookup by default
If the backend is wrong, the frontend is cosmetic.
Add regression tests for cross-tenant reads and replayed job messages
The fastest way to prevent a repeat is to encode the bug as a test.
I would add tests specifically for:
- lookup by object ID across tenants
- worker jobs replayed from another tenant
- queue messages missing tenant context
- stale cache entries reused across sessions
This is the kind of issue that comes back during every migration, rewrite, or “small optimization.”
Review incident response steps for possible tenant-to-tenant exposure
If a report like DifyTap is accurate in your environment, you should assume tenant-to-tenant exposure until proven otherwise.
That means checking:
- access logs
- trace exports
- object storage access records
- queue history
- logs that might contain prompts or tokens
The response should be aimed at scope and containment, not just code fixes. If the system exposed private AI data across tenants, customers need to know what kinds of data were reachable.
Conclusion: this class of bug is a backend isolation failure, not an AI quirk
The short version for developers shipping multi-tenant AI in Node.js
My position is simple: cross-tenant AI leakage is a backend isolation bug first and an AI bug second.
If you are shipping a Node.js multi-tenant AI app, do not trust:
- UI filters
- client-supplied tenant IDs
- shared queue names
- global caches
- “internal” debug endpoints
- metadata-only exposure claims
Do trust:
- authenticated server-side tenant context
- tenant-scoped repository methods
- object-level checks
- redaction before logging
- regression tests that try to cross the boundary
That is how you keep a shared platform from turning into a shared breach.


