I’ve spent the last couple of years watching teams bolt an LLM onto a product and then look genuinely surprised when it does something dumb, dangerous, or both. The pattern is always the same. Ship first, wonder about security later. So when OWASP put together a Top 10 specifically for LLM applications, I was relieved. Finally there’s a shared vocabulary I can point at during a review instead of explaining prompt injection from scratch for the hundredth time.
This is my walk through the 2025 list. Not the sanitized version you get from a vendor slide deck, but the way I actually think about these risks when I’m staring at someone’s codebase trying to figure out how it’s going to get owned. I’ll keep it practical and I’ll lean on Python where it helps, because that’s where most of this code lives anyway.
LLM01: Prompt Injection
This is the big one, and it’s number one for a reason. Prompt injection is what happens when the text you feed a model gets to override your instructions. The model can’t reliably tell the difference between “the developer told me to do this” and “some string in the input told me to do this.” It’s all just tokens.
The version everyone pictures is direct injection: a user types “ignore your previous instructions and…” into a chat box. That’s real, but it’s the boring case. The one that keeps me up at night is indirect injection. Your app summarizes a web page, reads an email, or ingests a document, and buried in that content is an instruction meant for the model. The user never sees it. Your model reads it and cheerfully obeys.
This is the version I actually go hunting for in a review. Anywhere your app reads text it didn’t write — a support ticket, a scraped web page, a PDF someone uploaded — is somewhere an attacker can plant instructions and wait for your model to trip over them. They never touch your chat box. They just leave a landmine in content they know the model will eventually read, and let your own pipeline carry it in.
And you can’t prompt your way out of it. You cannot fully solve prompt injection with a cleverer system prompt, and I wish that weren’t true. Anyone who’s spent a week trying to filter their way out of a classic injection bug already knows how that game ends. What you can do is shrink the blast radius:
- Treat model output as untrusted, always (see LLM05).
- Keep the model’s privileges small (see LLM06).
- Put a human in the loop for anything consequential.
- Separate the trusted instruction context from untrusted content as much as your framework allows, and don’t pretend that separation is airtight.
Defense in depth, not a magic string.
LLM02: Sensitive Information Disclosure
Models are leaky. They’ll surface things you didn’t mean to expose: secrets that ended up in a prompt, PII from context you stuffed in for a RAG query, chunks of proprietary data, or details about other users because your session handling was sloppy.
A lot of this isn’t exotic. It’s the same mistake developers have made forever, just with a new mouth to say it out loud. If you shove an API key into the system prompt “just for now,” assume it’s one clever question away from coming back out. If your retrieval step pulls documents without checking whether this user is allowed to see them, the model becomes an authorization bypass with a friendly tone.
What actually helps:
- Don’t put secrets in prompts. Fetch them server-side at the point you need them, not in the model’s context.
- Scrub and minimize what you send. If the model doesn’t need a Social Security number to do its job, don’t hand it one.
- Enforce access control before retrieval, on the documents themselves. The model is not your access control layer.
- Consider output filtering for known-sensitive patterns, but treat it as a backstop, not the plan.
# Enforce authorization at retrieval time, not after the model has already seen the data
def retrieve_context(user, query):
docs = vector_store.search(query, top_k=20)
# Filter to what THIS user is actually allowed to read
allowed = [d for d in docs if user.can_read(d.resource_id)]
return allowed[:5]
That filter is the whole game. Skip it and you’ve built a very expensive way to leak your database.
LLM03: Supply Chain
Where did your model come from? What’s inside that fine-tune you grabbed off a hub? Who maintains the plugin you installed with one command last Tuesday? Most teams can’t answer these questions, and that’s the vulnerability.
The LLM supply chain has all the classic problems plus a few new ones. Pre-trained models can carry baked-in backdoors. Model repositories can serve tampered artifacts. Third-party plugins and tools run with your app’s trust. And the traditional dependency mess is still there, because your AI app is still a normal app underneath with a requirements.txt full of things you’ve never read.
Treat model artifacts like any other dependency you wouldn’t blindly trust:
- Pull models and datasets from sources you can verify, and check integrity hashes.
- Keep an inventory. You can’t secure components you don’t know you have.
- Vet plugins and tools before you wire them into anything with real permissions.
- Keep your boring dependencies patched, because that’s still how a lot of people get in.
LLM04: Data and Model Poisoning
Poisoning is supply chain’s nastier cousin. Instead of shipping a bad component, the attacker corrupts the data that shapes the model’s behavior — training data, fine-tuning sets, or the documents feeding your RAG system.
A poisoned model passes every test you’d normally run. It behaves right up until it sees the trigger the attacker planted, and then it does what they want instead of what you want. If you’re fine-tuning on data scraped from anywhere the public can write, you’re not just training a model. You’re letting strangers vote on how it behaves.
Guard the pipeline like the sensitive thing it is:
- Know the provenance of every dataset. “We found it online” is not provenance.
- Validate and clean training and fine-tuning data. Watch for anomalies and outliers.
- Lock down and audit who can touch the data that shapes your model.
- Test for triggered behavior, not just average accuracy.
RAG counts here too. If your knowledge base ingests untrusted content, an attacker can poison your answers without ever touching your training run.
LLM05: Improper Output Handling
This is the one I see skipped most often, and it’s the one that turns a chatbot into a real breach. Improper output handling is when you take whatever the model produced and pass it straight into something that trusts it: your database, a shell, a browser, another service.
Think about what LLM output actually is. It’s a string an attacker may have influenced (see LLM01). If you render it into HTML without encoding, you’ve got XSS. If you drop it into a query, you’ve got SQL injection. If you eval it or feed it to a shell, congratulations, you’ve built remote code execution with extra steps.
Treat model output exactly like user input, because functionally that’s what it is. Every rule you already know for handling untrusted data applies:
# WRONG — model output rendered as raw HTML
return f"<div>{llm_response}</div>"
# WRONG — model output executed
exec(llm_generated_code)
# RIGHT — encode on output, parameterize queries, never execute
from markupsafe import escape
return f"<div>{escape(llm_response)}</div>"
Context-aware encoding, parameterized queries, and a hard “no, we do not execute model output” policy will save you here. This is old-school appsec hygiene, just applied to a new source of untrusted text.
LLM06: Excessive Agency
We keep handing these models the keys. Tools, plugins, database access, the ability to send email, hit APIs, move money. Then we act shocked when one of them does exactly what an attacker nudged it to do through prompt injection.
Excessive agency is the amplifier that makes the other risks dangerous. Prompt injection into a model that can only reply is embarrassing. Prompt injection into a model that can delete records or issue refunds is an incident. The damage is bounded by what the model is allowed to do, so bound it aggressively.
My checklist when I review agentic systems:
- Least privilege for tools. If the model only needs to read, don’t give it write. If it needs to read one table, don’t give it the whole database connection.
- Minimal tool surface. Every tool you expose is an attack path. Fewer tools, tighter scopes.
- Human approval for consequential actions. Anything irreversible or expensive gets a confirmation step performed by a human, not the model.
- Enforce permissions downstream. The tool itself should check authorization. Don’t rely on the model to “decide” it’s allowed.
If the worst prompt injection you can imagine only lets the model do things you’d be fine with any anonymous user doing, you’ve scoped agency correctly.
LLM07: System Prompt Leakage
A lot of teams treat the system prompt like a vault. They stuff instructions, business logic, and sometimes actual secrets into it and assume users will never see it. Users will absolutely see it. Getting a model to spill its system prompt is a party trick at this point.
The real issue here isn’t just that the prompt leaks. It’s that people put things in the prompt that shouldn’t depend on staying hidden. If your security model relies on the user not knowing the contents of your system prompt, you don’t have a security model.
So the fix isn’t “prevent leakage” so much as “stop relying on secrecy”:
- No credentials, keys, or connection strings in the prompt. Ever.
- Don’t encode authorization decisions in prompt text. Enforce them in real code, outside the model.
- Assume the system prompt is public and design so that’s fine.
If leaking the system prompt would be a shrug, you’ve done this right.
LLM08: Vector and Embedding Weaknesses
This one is newer and it maps directly to how much everyone leans on RAG now. Once you’ve got a vector database and an embedding pipeline, you’ve got a whole new attack surface that most people never threat-model.
The failure modes cluster around a few themes. Access control gets forgotten at the vector layer, so a retrieval query pulls documents the user should never see (this overlaps hard with LLM02). Untrusted content gets embedded and later poisons answers (overlaps with LLM04). And in multi-tenant setups, weak isolation lets one tenant’s data bleed into another tenant’s results, which is the kind of finding that ends up in a breach notification.
If you run RAG, and you probably do:
- Apply authorization at the vector store, tied to the actual user, on every query.
- Isolate tenants properly. Shared indexes with a
WHERE tenant_idbolted on are a classic place to mess this up. - Validate and track the provenance of anything you embed.
- Watch for retrieval that returns suspiciously more than it should.
LLM09: Misinformation
Models are confident. That confidence is not correlated with correctness, and that gap is the vulnerability. When your app presents fabricated output as fact and a human acts on it, that’s a security and safety problem, not just a quality one.
I put this in the security bucket because of overreliance. When people trust the model too much, they stop checking. Then the model hallucinates a nonexistent library and someone installs the malicious package an attacker registered under that exact name (yes, “slopsquatting” is a real thing now). Or it invents a legal citation, a dosage, a config value, and it ships.
You can’t make a model never wrong, but you can design for the fact that it will be:
- Ground answers in verified sources with retrieval instead of free-form generation where accuracy matters.
- Show sources so users can check the work.
- Keep humans in the loop for high-stakes decisions.
- Set expectations in the UI. A model that’s allowed to say “I don’t know” beats one that confidently makes something up.
LLM10: Unbounded Consumption
This one falls off threat models constantly, and it shouldn’t. Every call to your model costs you real money, and attackers know it. Leave that uncapped and you’ve handed them two ways to hurt you: a denial of service against your app, and a denial of wallet against your bill.
The attacks range from crude to clever. Flood the endpoint with expensive requests. Craft inputs that force maximum output length. Abuse an unauthenticated demo endpoint until your monthly bill has a new digit. And if you’re self-hosting, sustained querying can even be used to probe or extract model behavior over time.
The mitigations are the ones you already use for any expensive resource:
- Rate limit per user and per key, not just globally.
- Cap input and output token counts. Don’t let a single request run away.
- Set spending alerts and hard budget ceilings with your provider.
- Require auth on anything that costs you money. “It’s just a demo” is how the surprise invoice starts.
Where This Leaves You
If you read the whole list and thought “a lot of this is just normal appsec,” you’re right, and that’s the point I want to land on. Encode your output. Enforce least privilege. Validate your inputs. Guard your supply chain. Don’t trust secrets to stay secret. The LLM didn’t repeal any of that. It just added a fast-talking, occasionally hostile new component in the middle of your system that you now have to treat as untrusted.
The two ideas I’d tattoo on every AI project are simple. First, the model is an untrusted input source — everything it emits gets handled with the same suspicion as raw user input. Second, bound the blast radius — assume prompt injection will eventually succeed, and make sure that when it does, the model can’t reach anything that matters.
Get those two right and most of this Top 10 collapses into problems you already know how to solve. Ignore them and you’ll learn every item on this list the hard way, in production, in front of your customers. I’d rather you learn it here.
If you want the book-length treatment, I reviewed Steve Wilson’s The Developer’s Playbook for Large Language Model Security a while back and it pairs well with this list. It’s written by the person who ran the OWASP LLM effort, so it’s the same map in a lot more detail.