Agent memory poisoning explained
Prompt injection ends with the session. Memory poisoning does not. How hostile text becomes a stored fact, and how to isolate, expire and review it.
What agent memory poisoning is
Agent memory poisoning is an attack that gets hostile text written into an agent's long-term memory, so a later session retrieves it as a trusted fact. Prompt injection lives inside one context window and ends when that window closes. Memory poisoning does not end, because the agent wrote the attacker's text down and reads it back tomorrow.
That one difference changes what you have to defend. A prompt injection is an incident a restart clears. A poisoned memory is a state change in your system. It behaves like a bad row in a database: still there after the restart, served to whoever queries next, and nothing about the later session looks unusual to the person using it.
OWASP tracks this as ASI06, Memory and Context Poisoning, in the Top 10 for Agentic Applications published in December 2025. The clearest public measurement is MINJA, Memory Injection Attacks on LLM Agents via Query-Only Interaction, posted in March 2025 and presented at NeurIPS 2025. The claims about the attack below come from those two sources. The defences after them are operational, and they work with any store you self-host.
Why prompt injection ends and memory poisoning does not
A context window is per-run state. The model has no memory between calls, so everything it knows during a run was placed there by your code: the system prompt, the tool output, the conversation so far. When the run finishes, that state is thrown away. An injected instruction that arrived inside a fetched web page goes with it. Prompt injection against a coding agent is dangerous during the run and harmless after it, which is why "start a fresh session" is a real mitigation there.
Long-term memory exists to break that property on purpose. You want the agent to remember that this user runs Debian, or that the production database is read-only from this host. So a memory step writes a durable record, and a retrieval step loads matching records back into the next run.
The attacker wants the same property, for the same reason. One write, many reads. A poisoned record is retrieved every time a question is similar enough to it, in every session, for every user that store serves, until somebody deletes it.
How hostile text becomes a stored fact
The write path has four steps. Name each one, because the defences attach to different steps.
- The agent reads untrusted content: a web page, a support ticket, an email body, a README in a cloned repository.
- A memory step decides what is worth keeping. In most designs this is a second model call that summarises the session into short factual sentences.
- The extracted sentence is stored. A typical row holds the text, an embedding vector, a timestamp, and often a user id.
- A later session runs a similarity search and pastes the top matches into the prompt, usually above the user's message.
Step 2 is where the trust boundary is crossed, because the extractor's input contains the untrusted content while its output is treated as the agent's own conclusion. Step 3 is where the damage becomes durable, because extraction throws away where the text came from. A sentence the user typed and a sentence lifted out of a hostile page are stored in the same shape: text, vector, timestamp. After that write, no field in the row tells them apart.
Retrieval then does exactly what it was built to do. It ranks by similarity, not by trust, and it hands the winners to the model in the region of the prompt reserved for established context. The model has no way to know that one of those lines was written by a stranger.
What the MINJA paper measured, and what it did not
MINJA's contribution is its threat model. The attacker never touches the database. They send queries to the agent and read its answers, which is the level of access an ordinary user of a shared agent already has. The malicious record is written by the agent's own memory step.
The data behind this chart
[
{
"config": "EHRAgent GPT-4 MIMIC-III",
"injection_success_pct": 95.6,
"attack_success_pct": 57.0
},
{
"config": "EHRAgent GPT-4 eICU",
"injection_success_pct": 98.5,
"attack_success_pct": 90.0
},
{
"config": "RAP GPT-4 Webshop",
"injection_success_pct": 96.3,
"attack_success_pct": 77.4
},
{
"config": "RAP GPT-4o Webshop",
"injection_success_pct": 99.3,
"attack_success_pct": 98.9
},
{
"config": "QA GPT-4 MMLU",
"injection_success_pct": 100.0,
"attack_success_pct": 68.9
},
{
"config": "QA GPT-4o MMLU",
"injection_success_pct": 100.0,
"attack_success_pct": 68.9
},
{
"config": "Paper average",
"injection_success_pct": 98.2,
"attack_success_pct": 76.8
}
]Read the two metrics separately. Injection success is whether the malicious record reached the memory bank at all. Attack success is whether that record then steered the agent's answer to a later, different query. The paper reports an average of 98.2 percent for the first and 76.8 percent for the second. Getting text into memory was close to reliable. Making it change a later answer was not, and it moved a long way between agents: the retrieval-augmented shopping agent on GPT-4o reached 98.9 percent, while the clinical records agent on MIMIC-III reached 57.0 percent.
What these published figures are, and are not
These are averages from one paper's own experiments, run on GPT-4 and GPT-4o across three agent designs and four datasets. Every cell in the source table carries a standard deviation, and the spread on attack success is wide. They measure those systems, not yours. Treat them as evidence that query-only memory injection works on real agent designs, not as a probability for your deployment.
Why one user's input becomes another user's memory
The multi-tenant case turns a nuisance into a breach. Plenty of self-hosted setups run one agent with one memory store, and separate users with a metadata field: a tenant_id or user_id on each record, filtered at query time.
That design fails in two ordinary ways.
The first is a missing filter on the read. Retrieval gets called from more than one code path: the chat handler, a nightly summary job, an evaluation script somebody wrote in a hurry. If one path forgets the filter, nothing errors. It returns more rows, ranked by similarity, and the extra rows belong to other tenants. A missing filter fails open.
The second is the write. Tenant A's ticket text goes through the extractor, and the resulting "fact" is stored with whatever tenant id the code happened to set. If the summariser runs in a shared job, or if the record is written as a global preference because it read like general knowledge, then A's text is now B's retrieved context. The read filter was never wrong. The row was written on the wrong side of the boundary.
So an attacker who can type into any one tenant's agent can aim at every tenant that store serves. Nothing in the retrieval path checks who wrote a record.
Defence 1: one memory store per trust boundary
Decide where your trust boundaries are, then give each one its own store. Not one collection with a filter. A separate database, or at minimum a separate schema reached with separate credentials.
The reason is the direction each failure goes. A forgotten filter returns other people's rows and stays quiet. A wrong connection string returns nothing, and you find out in the first minute. Isolation that fails closed is worth the extra container.
Boundaries worth separating in most deployments:
- Each customer or team, when the agent is multi-tenant.
- Anything derived from public content, kept apart from anything derived from what your own users typed.
- Each agent role, when a privileged agent and a public-facing agent share a host.
- Development and production, so a test run cannot write a row that a real session will read.
When a shared table is unavoidable, push the check down into the database instead of trusting every call site. PostgreSQL row level security does this in three statements:
ALTER TABLE agent_memory ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_memory FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON agent_memory
USING (tenant_id = current_setting('app.tenant_id', true));Each request then names its tenant on the connection before it reads:
BEGIN;
SELECT set_config('app.tenant_id', 'acme', true);
SELECT content FROM agent_memory ORDER BY created_at DESC LIMIT 20;
COMMIT;The third argument true makes the setting local to the transaction, which matters because a pooled connection is handed to the next request afterwards. A code path that never sets it returns zero rows, because current_setting('app.tenant_id', true) is NULL when the setting is absent, and tenant_id = NULL is never true. That is the fail-closed behaviour you want, and you can prove it by running the second block without the set_config line.
Two things break this in practice. FORCE ROW LEVEL SECURITY is not decoration: without it the table owner bypasses every policy, so an application that connects as the owner reads all tenants and the policy looks broken. A role with BYPASSRLS, which superusers have, ignores policies for the same reason. Connect as an ordinary role that owns nothing.
Defence 2: record provenance, and treat every memory as untrusted input
Give the row the fields that extraction throws away.
CREATE TABLE agent_memory (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
content text NOT NULL,
source text NOT NULL, -- user_message, tool_output, web_page, operator
source_ref text, -- url, ticket id, session id
written_by text NOT NULL, -- which agent or job wrote the row
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
reviewed boolean NOT NULL DEFAULT false
);
CREATE INDEX agent_memory_tenant_idx ON agent_memory (tenant_id, created_at DESC);source is the field that pays for itself. With it you can answer the only question worth asking after a suspected incident: which stored facts came from content the user did not write. Without it every row looks equally legitimate, so your only remaining option is to delete the whole store and lose the good memories with the bad.
Provenance also gives you a retrieval policy you can state in one line. Rows whose source is user_message or operator are eligible for retrieval into a run that can call tools. Everything else needs a human to set reviewed first. That is portable: it is a WHERE clause, and it works the same whether the store is Postgres, SQLite or a vector database with metadata filters.
Then keep stored text out of the system prompt. Retrieved memories belong in their own clearly delimited block, labelled as recalled data. Labelling does not stop a model from following an instruction it finds there, and pretending otherwise is how people end up trusting a control that does nothing. It does two things you can rely on: the highest-trust region of the prompt stays free of attacker-reachable text, and the boundary is visible in your logs when you go looking for what the model was shown.
Defence 3: expire what you wrote, review what matters
Memory that never expires grows until nobody can read it. Give every extracted row a TTL (time to live) and delete on a schedule:
DELETE FROM agent_memory
WHERE expires_at IS NOT NULL
AND expires_at < now();A systemd timer is enough to run it:
[Unit]
Description=Delete expired agent memory rows
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.targetThe matching .service unit is one ExecStart line calling psql -d agentmem -f /etc/agent-memory/expire.sql, run as your maintenance role. Check it with systemctl list-timers agent-memory-expire.timer, which should show a next run time and a last run time. A timer that never fires is usually a timer you created but did not enable --now.
The TTL is the upper bound on how long a poisoned row can be retrieved, so a 30 day default caps the exposure at 30 days of sessions. Short defaults for extracted memories, longer life only for rows a person promoted on purpose. The same expiry job is where pruning stale agent memories belongs, since correctness and security want the same thing here.
Review the rows that can change behaviour rather than all of them, because a queue nobody reads is decoration. The rows worth a human glance are the ones naming credentials, tool names, URLs, or carrying words like "always" and "never". Keep the write log append-only and separate from the memory table, so deleting a poisoned row does not destroy the record of when it appeared and which session wrote it.
Defence 4: keep the memory store out of the agent's blast radius
The store is a database the agent talks to, so give it what any database gets. Its own container or host, no published port, and credentials that are not the credentials the agent uses for anything else:
docker network create agent-memAttach the database and the agent to that network and publish nothing. A store reachable only on an internal Docker network cannot be read from the internet even when the agent process is fully compromised.
Then narrow what the agent's own role may do:
CREATE ROLE agent_rw LOGIN PASSWORD 'generate-this-do-not-type-it';
GRANT SELECT, INSERT ON agent_memory TO agent_rw;
GRANT USAGE ON SEQUENCE agent_memory_id_seq TO agent_rw;No UPDATE, no DELETE. An agent talked into "correct your memory about this" cannot rewrite history, because the grant is not there, and the expiry timer runs as a different role. The failure the reader will hit while testing is permission denied for table agent_memory on an update, which is the control working.
The last piece is the one people skip. If the agent process holds cloud keys or deploy tokens in the same environment as its database password, then a poisoned memory that steers one tool call reaches all of it. Separate them, as in keeping secrets out of an AI agent's environment, and put the actions that matter behind an explicit approval step. If you run a dedicated memory service such as a self-hosted Mem0 server on a VPS, none of this changes: it is a database with a model in front of it, and both halves need the same treatment.
How do you know if memory is already poisoned?
The honest answer first. You will not tell by reading the text. The MINJA authors make this point about their own records, arguing that the injected content reads as plausible to input and output moderation, which is why they expect content filtering to miss it. A filter that scans stored sentences for something that looks malicious is a weak control here.
Bookkeeping works better than inspection:
- Query by provenance. List rows where
sourceis notuser_message, newest first. On a healthy store that list is short enough to read. - Watch the growth rate. A store that gains five rows a day and suddenly gains two hundred deserves attention whatever those rows say.
- Keep a fixed set of evaluation prompts and run them after memory changes. A behaviour change with no code change points at the store.
- Snapshot the store daily and diff the snapshots. A text diff is readable in a way that a vector search is not.
None of these is a detector. They are the difference between finding a poisoned row yourself and hearing about it from a customer.
What this does not fix
Isolation, provenance and expiry shorten the life of a poisoned record and narrow who it reaches. They do not stop the model from believing that record while it sits in the store. The real limit on damage is what the agent is allowed to do: an agent that cannot deploy or spend money without a human approval cannot do much with a false belief.
If your agent reads untrusted content and you cannot yet record provenance, run it without long-term memory. A stateless agent forgets the attack when the session ends. That costs real usefulness, and it is still the right trade until the store is separated and the write path is understood.
FAQ
Is memory poisoning just prompt injection with a longer name?
No. The entry point is the same, the lifetime is not. Prompt injection lives in one context window, so ending the session clears it. Memory poisoning gets the hostile text written into a durable store, so the next session retrieves it as an established fact and restarting changes nothing. OWASP separates them for that reason: prompt injection sits in the LLM Top 10, while persistent memory corruption is ASI06, Memory and Context Poisoning, in the Top 10 for Agentic Applications.
Can someone poison my agent's memory without access to the database?
Yes, and that is the finding of the MINJA paper. Its attacker only sends queries and reads the answers, with no access to the memory bank, and the agent's own memory step performs the write. Across their configurations the paper reports 98.2 percent average injection success and 76.8 percent average attack success. Any interface where an untrusted party's text can reach your extraction step is a write path, including a support inbox or a fetched web page.
Does giving every tenant its own collection fix multi-tenant leakage?
It fixes the read side only if every code path applies the filter, and that is the weak part: a forgotten filter returns other tenants' rows instead of an error, so nothing tells you. It does nothing for the write side, where one tenant's text is extracted into a record and stored as global or under the wrong id. Separate stores with separate credentials fail closed, because a wrong connection string returns no rows at all.
How long should agent memories live?
Shorter than feels comfortable. The TTL is the upper bound on how long a poisoned row can keep being retrieved, so a 30 day expiry caps the exposure at 30 days of sessions. Use a short default for anything a model extracted, and require a person to review a row before it becomes permanent. Store expires_at on the row itself so the expiry job is one DELETE, not a script that has to guess.