The Agent Security Gap Nobody Wants to Talk About

A recent survey of 107 enterprises running AI agents in production found that 54% have already had a confirmed agent security incident or a near-miss, yet only about a third give every agent its own scoped identity. Most agents still share credentials. Only three in ten isolate their highest-risk agents from the rest of the fleet.
I read that number and thought about every architecture review I've done in the last year. It tracks. The pattern I keep seeing is the same: a team ships an agent that works, gives it broad access "for now", forgets to come back, and then wonders why a prompt injection turned into a data exfiltration ticket. Shared agent credentials are the new SSRF. Everyone knows they're dangerous. Nobody wants to be the one to refactor them out.
This post is what I actually do about it, in code and in the IAM console, when a client asks me to lock down a fleet of agents that grew faster than the security around them.
Why shared credentials break agent security in a way humans never did
When a human engineer uses a service account, there's a person behind the request. If something goes wrong you can ask them what they did, why, and when. With an agent sharing that same credential, you get a request from agent-service-role with no context about which task, which user, which prompt, or which tool call triggered it. Attribution collapses. Blast radius expands.
The traditional model, one service principal per service, was built for deterministic software. Agents are not deterministic. A single agent runtime might be executing dozens of different tasks concurrently on behalf of different users, invoking different tools, calling different sub-agents. Giving that runtime one shared identity means:
- No per-task audit trail. CloudTrail sees the runtime role, not the task.
- No least-privilege enforcement per user context. The agent has the union of all permissions any of its tasks might need.
- No blast radius containment. A prompt injection in task A can trigger tool calls that only task B should be allowed to make.
- No revocation granularity. You can't kill one bad task without killing every task on that runtime.
That last one hits hardest during an incident. When you can't scope revocation, your only option is to disable the whole agent. I've watched teams eat hours of downtime because they couldn't isolate a single compromised session.
The per-agent scoped identity pattern that actually holds up
Here's the model I use. Each agent invocation gets a short-lived credential minted specifically for that task, scoped to the exact tools, resources, and user context it needs. Nothing more.
On AWS, that means the agent runtime doesn't hold long-lived credentials for the target systems. It holds one narrow permission: sts:AssumeRole against a set of task-scoped roles, with session tags that carry the invocation context.
def mint_task_credentials(agent_id, task_id, user_id, tools_allowed):
sts = boto3.client("sts")
response = sts.assume_role(
RoleArn=TASK_ROLE_ARN,
RoleSessionName=f"{agent_id}-{task_id}"[:64],
DurationSeconds=900, # 15 min max
Tags=[
{"Key": "AgentId", "Value": agent_id},
{"Key": "TaskId", "Value": task_id},
{"Key": "UserId", "Value": user_id},
],
Policy=build_inline_policy(tools_allowed, user_id),
)
return response["Credentials"]
The build_inline_policy call is where the real work happens. It returns a session policy that intersects with the role's permission boundary, so even if the role is over-privileged in some future version, the session credential is not. The session tags then propagate into every downstream call, so CloudTrail and your data plane can filter by AgentId, TaskId, and UserId.
A session policy might look like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::user-docs/${aws:PrincipalTag/UserId}/*"
},
{
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"DateGreaterThan": {"aws:CurrentTime": "2026-07-17T18:00:00Z"}
}
}
]
}
Two things worth noting. First, the resource ARN uses the session tag as a variable, so an agent invoked for user A physically cannot read user B's documents even if the LLM decides that would be a great idea. Second, the explicit time-based deny is belt-and-suspenders on top of the 15-minute session duration. In an incident, that Deny block is what you rewrite to shut down a class of sessions without touching the role.
Isolating the highest-risk agents: three tiers I actually deploy
The finding that only three in ten enterprises isolate their highest-risk agents matches what I see in the wild. Teams put a code-writing agent, a customer support agent, and an internal analytics agent on the same runtime, same VPC, same egress path. Then someone lands a prompt injection through a support ticket and suddenly the code agent is being asked interesting questions.
I sort agents into three tiers and enforce isolation at the infrastructure level, not the application level.
| Tier | Examples | Isolation |
|---|---|---|
| Low | Read-only summarizers, internal chat over public docs | Shared runtime, per-task credentials, standard egress |
| Medium | Support agents with CRM access, RAG over customer data | Dedicated runtime per tenant class, egress allowlist, per-task credentials with user tags |
| High | Code execution, financial actions, admin API access | Dedicated VPC, dedicated runtime, no shared secrets manager, egress deny-by-default, human-in-the-loop for irreversible actions |
The tier assignment is not about how smart the agent is. It's about what it can touch. A "simple" agent that can trigger a refund is High tier. A "complex" agent that can only read a knowledge base is Low.
The isolation itself is the boring part: separate AWS accounts for High tier, VPC endpoints for everything so nothing leaves the private network unless it must, and a network egress policy that denies by default. If a High tier agent needs to hit an external API, that API goes on an explicit allowlist and traffic is logged. When an agent starts probing IPs it shouldn't, you see it in the flow logs before you see it in the incident report.
What "agent incident" actually looks like in practice
The survey talks about incidents and near-misses in the aggregate. Here's what the patterns look like when you're the one paged at 2am.
Pattern 1: Tool call amplification. An agent has access to a "send email" tool. A prompt injection in an incoming document convinces the agent to send its system prompt, tool list, and recent context to an attacker-controlled address. No data exfiltration in the traditional sense. Just the keys to the kingdom, mailed out.
Pattern 2: Credential leakage through logs. The agent is instructed (by an attacker input) to include environment variables or the contents of a config file in its next reasoning step. Those get logged to your observability platform, which is now storing plaintext secrets. I've seen this one specifically bite teams using verbose LangChain tracing with no redaction layer.
Pattern 3: Cross-tenant contamination. An agent runtime caches embeddings, prompts, or intermediate reasoning across requests. A slow leak of data from tenant A appears in tenant B's responses. This is the one nobody catches until a customer complains, and then you find it's been happening for weeks.
Pattern 4: Recursive tool spend. Not a security incident in the classic sense, but a real financial incident. An agent gets into a loop where it calls a paid tool thousands of times in a few minutes. If your budget guardrails live inside the agent's own reasoning, they don't work when the agent's reasoning is what's broken.
Every one of these is worse when credentials are shared. Pattern 1 sends the shared token. Pattern 2 leaks the shared secret. Pattern 3 has more surface area because there's no per-task boundary. Pattern 4 can't be stopped by revoking one session because there are no sessions.
The IAM patterns I trust in production
A checklist I actually work through when reviewing an agent architecture. If a client checks fewer than seven of these, I flag it before we ship.
- Every agent invocation gets its own STS session, not longer than 15 minutes.
- Session policies intersect with a permission boundary that no code can widen.
- User context is carried as session tags and enforced with
${aws:PrincipalTag/...}in resource ARNs. - The agent runtime's own role can only assume task roles. It has no direct data access.
- Secrets Manager access is scoped by secret path, not
secretsmanager:*onResource: *. - All tool invocations pass through a policy layer that can deny based on intent, not just permissions. An agent might have permission to send emails, but the policy layer says "not to external domains during an incident."
- Egress is deny-by-default from any high-tier runtime. Allowlist explicit destinations, log everything else.
- Logs and traces are redacted before storage, not after. Assume anything that hits your log pipeline is compromised.
- Irreversible actions require a second signal. Human approval, a second agent, or a heuristic circuit breaker.
- Kill switches exist per agent, per tool, per tenant, and are tested monthly.
Number 10 is the one people skip and regret. The first time you need to kill "all support-agent invocations for tenant X, but leave the rest running," you find out whether you built a real control plane or a wish.
The policy layer, in code
The "policy layer" in point 6 is where I've seen the best return on effort. It sits between the agent's tool selection and the actual tool execution. It's a small piece of deterministic code, not an LLM, and it enforces rules that don't belong in the model's context.
def enforce_tool_policy(agent_ctx, tool_name, tool_args):
if tool_name == "send_email":
domain = tool_args["to"].split("@")[-1]
if domain not in agent_ctx.allowed_email_domains:
raise ToolDenied(f"External email to {domain} not allowed")
if tool_name in IRREVERSIBLE_TOOLS:
if not agent_ctx.human_approval_token:
raise ApprovalRequired(tool_name)
if agent_ctx.spend_this_minute > agent_ctx.spend_cap_per_minute:
raise RateLimited("Tool spend cap exceeded")
return True
That's it. No LLM. No fancy reasoning. Just rules the model cannot talk its way past, because it never sees them.
What I'd do if I inherited an agent fleet tomorrow
If a CTO handed me a fleet of agents already in production and said "the survey scared me, fix this," here's my order of operations.
- Inventory first. List every agent, every tool it can call, every credential it holds. Half the fixes are obvious once the list exists.
- Kill shared long-lived credentials. Route everything through STS with 15-minute sessions and session tags. This is one to two weeks of work and it removes the biggest single risk.
- Introduce the policy layer. Even a minimal deny-list for external egress, irreversible actions, and spend caps will catch 80% of near-misses.
- Tier the agents. Move High-tier agents to their own runtime, own account if you can. Egress deny-by-default.
- Fix logs. Redaction at ingestion. Assume your traces are a target.
- Build the kill switches. Test them. Actually test them, not "we have a runbook."
- Then, and only then, add more agents.
The order matters. Building more agents on a broken foundation is the pattern that produced the 54% incident rate in the first place.
Closing thought
The gap between what agents can do and what enterprises have built to contain them is the biggest unsexy problem in AI right now. Model capability moved fast, tool integration moved faster, and identity and access control got left behind because it feels like plumbing. It is plumbing. Plumbing is what stops your house from flooding.
If you're building or scaling an agent system and any of this sounds familiar, I'm happy to take a look. You can reach me at lazar-milicevic.com/#contact, or read more of what I've written about running agents in production on the blog.
Frequently asked questions
Why are shared credentials dangerous for AI agents?
Shared credentials collapse attribution and expand blast radius in ways that don't happen with human-operated service accounts. When multiple agent tasks share one identity, CloudTrail only sees the runtime role, not which task, user, or tool call triggered a request, so you get no per-task audit trail and no least-privilege enforcement per user context. A prompt injection in one task can trigger tool calls that only another task should be allowed to make. Worst of all, you can't revoke a single compromised session without killing every task on that runtime, which is why teams eat hours of downtime during incidents.
How do I give each AI agent its own scoped identity on AWS?
I use STS AssumeRole to mint short-lived credentials for each agent invocation, rather than letting the agent runtime hold long-lived credentials for target systems. The runtime only gets sts:AssumeRole permission against task-scoped roles, and each AssumeRole call passes session tags carrying AgentId, TaskId, and UserId, a session duration of 15 minutes, and an inline session policy that intersects with the role's permissions. Those session tags propagate into every downstream call, so CloudTrail and your data plane can filter by agent, task, and user. This gives you per-task audit trails and granular revocation without refactoring the whole agent.
How do I prevent an AI agent from accessing the wrong user's data?
I embed the user identity in a session tag and reference it directly in the resource ARN of the session policy, for example using ${aws:PrincipalTag/UserId} in an S3 resource path. That way an agent invoked for user A physically cannot read user B's documents even if the LLM decides it would be a great idea to try. I pair that with a short session duration (15 minutes) and an explicit time-based Deny statement as belt-and-suspenders. During an incident, rewriting that Deny block lets you shut down a class of sessions without touching the underlying role.
How should I isolate high-risk AI agents from low-risk ones?
I sort agents into three tiers based on what they can touch, not how sophisticated they are, and enforce isolation at the infrastructure level rather than the application level. Low-tier agents (read-only summarizers) can share a runtime with per-task credentials. Medium-tier agents (support agents with CRM access) get a dedicated runtime per tenant class with egress allowlists. High-tier agents (code execution, financial actions, admin APIs) get a dedicated VPC, dedicated runtime, no shared secrets manager, egress deny-by-default, and human-in-the-loop for irreversible actions. A 'simple' agent that can trigger a refund is High tier because tier is about blast radius, not complexity.
What percentage of enterprises have had an AI agent security incident?
A recent survey of 107 enterprises running AI agents in production found that 54% have already had a confirmed agent security incident or a near-miss. Despite that, only about a third of those enterprises give every agent its own scoped identity, and only three in ten isolate their highest-risk agents from the rest of the fleet. Most agents still share credentials, which is the root cause I see behind most of the incidents in the architecture reviews I do. Shared agent credentials are effectively the new SSRF: everyone knows they're dangerous, but nobody wants to refactor them out.
Building something hard with AI or automation? I am open to talk.
Get in touch