DEV Community

Mukesh
Mukesh

Posted on

Give Your Mem0 Agent Session-Scoped Memory in 15 Minutes (One Filter You're Probably Skipping)

You add memory to your agent with Mem0, ship it, and it works great in your dev environment where you're the only user. Then you go multi-tenant — real users, real sessions — and three weeks later someone reports that the agent "remembers" something they never told it. It's not a hallucination. It's another user's memory, served straight from your own vector store.

This is the single most common Mem0 integration bug I run into, and the fix is one filter you're probably not passing consistently. Here's the 15-minute version.

The setup that causes it

Most Mem0 quickstarts look like this:

from mem0 import Memory

m = Memory()

m.add(
    "I prefer flights with no layovers and I'm vegetarian",
    user_id="alice",
)
Enter fullscreen mode Exit fullscreen mode

That looks scoped — you passed user_id="alice". The bug isn't in add(). It's in search(), three files away, written by a different part of the team (or you, two sprints later) without the same discipline:

# somewhere in the RAG/retrieval layer
relevant = m.search(query="what are the user's travel preferences?")
Enter fullscreen mode Exit fullscreen mode

No user_id. No filters. Mem0 will happily return the closest semantic matches across every memory in the store — Alice's vegetarian preference bleeding into Bob's session, or worse, into an agent that's actively talking to Bob. The write path was scoped. The read path wasn't. Because both calls succeed and return plausible-looking data, this ships, passes QA (one tester, one session), and only shows up once you have concurrent real users.

The one-line fix

Every search() call needs the same scoping identity as the add() call that created the memory. If you passed user_id on write, pass it on every read:

relevant = m.search(
    query="what are the user's travel preferences?",
    user_id="alice",
)
Enter fullscreen mode Exit fullscreen mode

That's the 80% fix. But scoping by user_id alone isn't enough once you have more than one agent or more than one conversation thread per user — which is most real products within a month of launch.

The part people miss: agent_id and run_id

Mem0 supports three identity dimensions, not one: user_id, agent_id, and run_id. If your product has multiple agents (a support bot and a booking bot, say) sharing the same user base, scoping by user_id alone means the booking bot's memories leak into the support bot's context — technically the right user, wrong agent, still a correctness bug.

m.add(
    "User wants the booking bot to always confirm price before charging",
    user_id="alice",
    agent_id="booking-bot",
)

m.add(
    "User asked support to stop sending SMS notifications",
    user_id="alice",
    agent_id="support-bot",
)

# retrieval inside booking-bot's context
relevant = m.search(
    query="payment preferences",
    user_id="alice",
    agent_id="booking-bot",
)
Enter fullscreen mode Exit fullscreen mode

Without agent_id on both calls, search() from the booking bot can surface the SMS-notification memory that belongs to a completely different conversational context. It's not wrong data exactly — it's real, it's Alice's — but it's the wrong memory for this agent to be reasoning with, and it will show up in the prompt as if it's relevant.

run_id is the same idea one level down: scope to a single session or task run when you don't want memory to carry across unrelated conversations with the same agent (a returning support ticket vs. an old, resolved one, for example).

Advanced filtering for anything beyond exact match

Once you're past simple identity scoping, Mem0's platform API accepts a filters dict with AND/OR logic on top of metadata you attach at write time — useful for things like "only memories from the last 30 days" or "only memories tagged billing":

m.add(
    "Card ending 4242 declined for insufficient funds",
    user_id="alice",
    agent_id="billing-bot",
    metadata={"category": "payment_issue", "resolved": False},
)

relevant = m.search(
    query="payment issues",
    user_id="alice",
    agent_id="billing-bot",
    filters={
        "AND": [
            {"category": "payment_issue"},
            {"resolved": False},
        ]
    },
)
Enter fullscreen mode Exit fullscreen mode

This is what turns "the agent remembers everything about this user" into "the agent remembers the right thing for this exact context" — which is the actual goal, not raw recall.

The 15-minute checklist

Go do this right now, it's faster than reading the rest of this article twice:

  1. Grep your codebase for every .search( call against your Mem0 client.
  2. For each one, check it passes the same user_id (and agent_id/run_id if you use them) as the add() calls that populate that memory space.
  3. Any search() call missing scoping is a live cross-tenant leak — fix it before anything else on this list.
  4. If two agents share a user base, add agent_id to every add/search pair, not just the ones you've noticed problems with.
  5. Write one test: add a scoped memory for user_id="test-a", search as user_id="test-b", assert the result is empty. This is the regression test that catches the bug before your users do.

The underlying lesson generalizes past Mem0: any memory or retrieval layer that supports scoping only prevents leaks if scoping is enforced symmetrically on both write and read. Write-side discipline without read-side discipline isn't partial protection — it's a false sense of security with a matching demo that works perfectly until a second user shows up.

Top comments (8)

Collapse
 
heinrichneb profile image
Heinrich Neb

The write/read asymmetry is the right frame, and I want to report the version one level down, because it is the one that got us and your checklist would have passed.

We had scoping enforced on the write path and on the read path. Group-restricted entries were correctly filtered out at retrieval time, verified, tested. Then someone pointed at a second read path - a lookup by exact name rather than by similarity search - and it returned the full content to anyone who asked. Not a missing filter on the read path. A second read path nobody had counted as one.

Your regression test is the right test and it would have gone green, because it tests one entry point. The generalisation I would add to it: the symmetry is not two-sided, it is n-sided - one side per entry point that can return content. Search, exact lookup, list, export, whatever the admin console calls, the debug endpoint someone added for a demo. Each one is a read path and each one needs the check independently.

Two things made ours findable, and both are cheap:

  1. The signature is the tell, and it greps. Our second path could not enforce scoping because it never received the caller identity - it took a topic and nothing else. It was not a forgotten filter, it was a function that had no way to know who was asking. So the check that actually generalises is not "does every search pass user_id" but "does every function that can return stored content accept the scoping identity in its signature." A parameter that isn't there cannot be forgotten later; it is already missing. That is a lint rule, not a test.

  2. Absent identity has to mean no, not "unscoped". When we added the check, the interesting case was a call with no caller identity at all. The tempting default is to treat it as unrestricted - it's an internal call, it's a batch job, it has no session. We made it refuse instead, and that decision is what turns the parameter from documentation into a boundary. If missing identity means "skip the filter", every code path that forgets to pass it is a leak wearing a green test.

One detail from the same fix that I have not seen written down anywhere, and which matters more than it sounds: the refusal has to be word-identical to "not found". If a denied request answers "you are not authorised to see this" and a genuinely absent one answers "no results", the error message is itself the leak - an attacker enumerates names and learns which ones exist without ever seeing content. Ours returns the same sentence for both, deliberately, and there is a test asserting the two strings match.

On @crdtcto's point about relevance ≠ authorization: agreed, and the sharpest version I know is that a vector store has no concept of "may not". It will always rank by similarity, so the boundary has to sit somewhere it cannot reach - before the query or after the results, but never inside the ranking. Any design where the scope is a scoring factor rather than a filter is one tuning change away from being bypassed by a sufficiently similar query.

Collapse
 
crdtcto profile image
Kane Lim

The distinction between a missing authorization check and a missing authorization capability is particularly important here.

If a function capable of returning stored content does not receive caller/scope identity, then authorization cannot be reliably enforced downstream. That makes the function signature itself part of the security boundary.

I especially like the rule that missing identity must fail closed. Treating absent identity as “internal/trusted/unrestricted” creates exactly the kind of implicit bypass that integration tests rarely catch. Making identity mandatory also gives you something static analysis can reason about: every content-returning API must carry the authorization context.

I’d take the n-sided model one step further and enforce it architecturally rather than relying only on individual developers remembering the rule:

untrusted caller → authorization context → repository/data-access layer → content

Ideally, application code shouldn't be able to call a content-returning repository method without an authorization context. That turns authorization from a convention into an invariant.

The indistinguishable “not found” response is another excellent detail. Authorization isn't only about preventing content disclosure; it also has to prevent existence disclosure. Otherwise exact-name lookup becomes an enumeration oracle even when the payload itself remains protected.

And your vector-store observation is critical: relevance and authorization belong to different dimensions. Authorization should constrain the candidate/result set; similarity should rank only within that authorized set. Using authorization as a scoring signal fundamentally weakens the boundary.

This is the kind of failure mode that becomes increasingly important as systems accumulate “temporary” endpoints, exports, admin tooling, background jobs, and alternate lookup mechanisms. The security model needs to follow the data-returning capability, not the original feature that introduced it.

I work on similar AI/backend automation and security-boundary problems with a small remote development team in Canada. If you're interested in exchanging architecture patterns or discussing longer-term engineering collaboration, I'd be glad to connect.

Collapse
 
heinrichneb profile image
Heinrich Neb

The reframing from "missing check" to missing capability is sharp. A function that can't receive scope identity can't be fixed by a more careful caller - that's a signature problem wearing a code-review costume.

Your architectural version is the right escalation, and the enumeration-oracle point is one I'd underweighted: protecting the payload while leaking existence still hands over a working lookup. And the vector-store observation deserves to be a rule on its own - authorization constrains the candidate set, similarity only orders what's already allowed. The moment authorization becomes a score, it's negotiable.

One genuine question, because I don't have a good answer: how do you keep the invariant true for the endpoints that arrive later? The "temporary" export, the admin tool, the background job - they're exactly the ones written under time pressure by someone who wasn't in the original design conversation. Is there something that makes the wrong version not compile, or does it come down to review?

And yes to swapping patterns - I'd like that. My focus is very heavily on cachly right now, so I'm not in a position to take on much beyond it. But the problems clearly overlap more than they don't: we build shared memory for AI assistants, which means every new way to return stored content is another place the boundary can quietly stop applying. That's your "the security model follows the data-returning capability," arrived at from the other side.

So there's almost certainly a useful form for this, even if it's just the two of us comparing notes in public.

Concretely, and low-effort for both of us: if you ever write up how you enforce the invariant structurally - the untrusted-caller-to-repository chain, and what stops someone bypassing it - I'll read it properly and reply in detail. You can also reach me on GitHub anytime. Happy to keep the conversation going, and our work is public at github.com/cachly-dev if you want to see what we're actually dealing with.

Thanks for the depth on this one.

Collapse
 
crdtcto profile image
Kane Lim

Can we get to know each other a little better?

Thread Thread
 
heinrichneb profile image
Heinrich Neb

Sure. What have you in mind?

Thread Thread
 
crdtcto profile image
Kane Lim

I would like to get to know you better. Would you please contact me? t_g_@CRDT_CTO

Collapse
 
deanlee profile image
Dean Lee

The read-side boundary is the part I would test first. A scoped write path gives you a comforting demo, but the failure only appears when retrieval has two plausible users in the same store. Semantic search makes that bug look normal until the wrong preference shows up in a real session.

Collapse
 
crdtcto profile image
Kane Lim

This is a very important distinction: memory correctness is primarily a retrieval-boundary problem, not just a storage problem.

The symmetric scoping principle you describe is exactly right. A system can have perfectly isolated add() operations and still become a cross-tenant data leak if retrieval doesn't enforce the same identity constraints.

I’d take the implementation one step further by making the scope non-optional at the application boundary. Instead of allowing developers throughout the codebase to call m.search() directly, wrap Mem0 behind a session-aware memory service that requires something like:

tenant_id + user_id + agent_id + session/run_id

Then enforce those fields server-side rather than trusting individual callers to remember them. This also makes it much easier to audit and test.

I especially like the negative test you suggested. I’d expand it into an isolation matrix:

User A → User B: no results
Agent A → Agent B: no results
Session A → Session B: no results
Tenant A → Tenant B: no results
Correct scope → expected results

And importantly, test both semantic retrieval and metadata filtering. A filter bug can be just as dangerous as a missing user_id.

There’s also a subtle architectural point here: relevance ≠ authorization. A vector database may correctly determine that another user's memory is semantically relevant, but that doesn't mean the current agent is authorized to see it. Authorization/scoping must happen before the LLM receives the retrieved context.

For production systems, I’d also recommend logging the effective retrieval scope (without logging sensitive memory contents) so unexpected cross-scope queries can be detected and investigated.

This pattern generalizes extremely well beyond Mem0 to RAG pipelines, vector databases, cached agent state, and multi-tenant AI systems.

Great write-up. The "one missing filter" framing is simple, but the underlying lesson is much bigger: retrieval boundaries should be enforced as an architectural invariant, not a developer convention.

I’d be interested in exchanging ideas around production-grade multi-agent/RAG architecture and long-term AI automation projects.