Least-Privilege IAM for AI Agents

Quick answer
ReadOnlyAccess grants ssm:Get*, s3:GetObject and lambda:GetFunction — it is a credential-read policy wearing a safe-sounding name. Here's how to scope an AWS role for an agent that inspects infrastructure.
- ReadOnlyAccess reads your data plane
- Build the ceiling out of Deny
- The allow list is smaller than you think
- What Describe returns is still the payload
- Deliver the credential, don't mint one
11 min read · Security
Least-Privilege IAM for AI Agents
You have an agent that inspects AWS infrastructure — it answers "why is this ALB returning 503s," so it needs to describe target groups, look at security groups, and check what the Auto Scaling group is doing. Someone attaches arn:aws:iam::aws:policy/ReadOnlyAccess to its role, because it is read-only and read-only is safe.
It isn't. ReadOnlyAccess is a policy that reads the contents of things — Parameter Store SecureStrings, Lambda environment variables, S3 objects, DynamoDB tables.
This is the AWS half of a problem covered on the Kubernetes side in Give an AI Agent Read-Only Access to Kubernetes. The shape of the argument transfers; almost none of the mechanics do. IAM has no view role to distrust and no aggregation to worry about. What it has instead is a huge blast radius hiding behind a wildcard, and a policy language expressive enough to fix it properly.
ReadOnlyAccess reads your data plane
Look at the actual policy document (v188, last updated 21 July 2026). Among the wildcards:
ssm:Get*—GetParameter,GetParameters,GetParametersByPath. Call any of them withWithDecryptionand every SecureString parameter encrypted under the AWS managedalias/aws/ssmkey comes back in plaintext: that key grantsDecryptto every IAM principal in the account, so no additional KMS grant is needed in the policy.lambda:Get*—GetFunctionreturns the function's environment variables and a download link for the deployment package, valid for 10 minutes. Every hardcoded API key in your Lambda config, plus the source.s3:Get*— object contents, not just bucket configuration.dynamodb:Scan,dynamodb:Query,dynamodb:PartiQLSelect— full table reads.ecr:Get*— includingGetAuthorizationToken, a registry pull credential.rds:Download*— database log files.
Two things it doesn't grant, and both are worth knowing precisely. There is no kms:Decrypt — the policy carries kms:Describe*, kms:Get* and kms:List* and nothing else, so a SecureString sitting under a customer managed key stays opaque. And there is no secretsmanager:GetSecretValue: the Secrets Manager grants are Describe*, List* and GetResourcePolicy, which is metadata and policy, not secret material. So the account's Secrets Manager values survive ReadOnlyAccess, and every Parameter Store SecureString on the default key does not. That split is a narrow accident of how one managed policy was assembled, not a design you should lean on — a Secrets Manager exception could be reworded into Get* in the next revision.
For a human auditor with a laptop and an NDA, this is a defensible trade. For an agent, every one of those responses is serialised into a prompt and shipped to a model provider. ReadOnlyAccess isn't a permission set for an agent; it's a bulk export job with a scheduler attached.
ViewOnlyAccess is the closer managed policy — it deliberately excludes the data-plane reads. It's still account-wide, so use it as a reference point rather than a destination.
Build the ceiling out of Deny
Explicit Deny beats every Allow, from any policy, forever. That makes it the AWS-native hard boundary, and it's the one thing in an agent's permission set that should not need revisiting when someone widens the allow list next quarter.
Write the ceiling as its own managed policy and attach it to the agent role alongside whatever grants the agent needs. It fails closed as the allow list grows.
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Sid": "DenyCredentialMaterial",
6 "Effect": "Deny",
7 "Action": [
8 "secretsmanager:GetSecretValue",
9 "secretsmanager:BatchGetSecretValue",
10 "ssm:GetParameter",
11 "ssm:GetParameters",
12 "ssm:GetParametersByPath",
13 "kms:Decrypt",
14 "ecr:GetAuthorizationToken",
15 "sts:AssumeRole",
16 "sts:AssumeRoleWithWebIdentity",
17 "sts:GetSessionToken",
18 "sts:GetFederationToken"
19 ],
20 "Resource": "*"
21 },
22 {
23 "Sid": "DenyDataPlaneReads",
24 "Effect": "Deny",
25 "Action": [
26 "s3:GetObject",
27 "s3:GetObjectVersion",
28 "dynamodb:GetItem",
29 "dynamodb:BatchGetItem",
30 "dynamodb:Query",
31 "dynamodb:Scan",
32 "dynamodb:PartiQLSelect",
33 "sqs:ReceiveMessage",
34 "lambda:GetFunction",
35 "lambda:GetFunctionConfiguration",
36 "rds:DownloadDBLogFilePortion"
37 ],
38 "Resource": "*"
39 },
40 {
41 "Sid": "DenyOutsideOperatingRegions",
42 "Effect": "Deny",
43 "NotAction": [
44 "iam:Get*",
45 "iam:List*",
46 "sts:GetCallerIdentity",
47 "route53:Get*",
48 "route53:List*",
49 "cloudfront:Get*",
50 "cloudfront:List*"
51 ],
52 "Resource": "*",
53 "Condition": {
54 "StringNotEquals": {
55 "aws:RequestedRegion": ["eu-west-1", "eu-central-1"]
56 }
57 }
58 },
59 {
60 "Sid": "DenyOffNetwork",
61 "Effect": "Deny",
62 "Action": "*",
63 "Resource": "*",
64 "Condition": {
65 "StringNotEquals": {
66 "aws:SourceVpce": "vpce-0abc123def4567890"
67 }
68 }
69 }
70 ]
71}Three things in there are worth explaining.
sts:AssumeRole is in the credential list on purpose. A role that can assume another role has no ceiling — only the ceiling of wherever it lands. Denying assumption is what makes the rest of the policy an actual boundary rather than a starting position.
lambda:GetFunctionConfiguration is denied alongside GetFunction. It returns Environment.Variables too. The cost is real: the agent can enumerate functions with lambda:ListFunctions but can't see their memory, timeout, or VPC config. If your agent genuinely needs that, the fix is a wrapper API that strips Environment before returning — not widening IAM.
DenyOffNetwork fails closed by design. aws:SourceVpce is only present in the request context when the call actually goes through a VPC endpoint. With StringNotEquals, a missing key evaluates to true, so any request off the public endpoint hits the Deny. That's the behaviour you want, and it's also why this statement will break sts:GetCallerIdentity unless STS is reachable via an interface endpoint in the same VPC. It has a second edge: when an AWS service calls another service on your behalf the source-VPC context is dropped, so AWS's own guidance on this key is to exclude service principals from the Deny with "Bool": {"aws:PrincipalIsAWSService": "false"} rather than discover the problem in production. Check your endpoint coverage before shipping it — AWS VPC Design for EKS covers the endpoint layout.
Server & SSH Hardening Checklist
The firewall, SSH, fail2ban, and update baseline every internet-facing Linux box should pass. Plain Markdown you can run down in an afternoon.
Free. Instant download. You'll also get the occasional deep-dive from the newsletter — unsubscribe anytime.
The allow list is smaller than you think
With the ceiling in place, the grant is boring:
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Sid": "InspectComputeAndNetwork",
6 "Effect": "Allow",
7 "Action": [
8 "ec2:DescribeInstances",
9 "ec2:DescribeInstanceStatus",
10 "ec2:DescribeSecurityGroups",
11 "ec2:DescribeSubnets",
12 "ec2:DescribeRouteTables",
13 "ec2:DescribeNatGateways",
14 "elasticloadbalancing:DescribeLoadBalancers",
15 "elasticloadbalancing:DescribeTargetGroups",
16 "elasticloadbalancing:DescribeTargetHealth",
17 "autoscaling:DescribeAutoScalingGroups",
18 "eks:ListClusters",
19 "eks:DescribeCluster",
20 "eks:ListNodegroups",
21 "eks:DescribeNodegroup",
22 "rds:DescribeDBInstances",
23 "rds:DescribeDBClusters",
24 "cloudwatch:ListMetrics",
25 "cloudwatch:GetMetricData"
26 ],
27 "Resource": "*",
28 "Condition": {
29 "StringEquals": {
30 "aws:RequestedRegion": ["eu-west-1", "eu-central-1"]
31 }
32 }
33 }
34 ]
35}"Resource": "*" is not laziness. The EC2 Describe* actions do not support resource-level permissions — they must be granted against *, and aws:ResourceTag conditions have nothing to attach to. This trips people up constantly: they write a tag-scoped describe policy, it silently grants everything or nothing, and they never find out. Before you reach for a tag condition, check the action in the Service Authorization Reference. If the Resource types column shows only *, tag conditions are decoration.
Where tags do apply, use them as another Deny:
1{
2 "Sid": "DenyRestrictedResources",
3 "Effect": "Deny",
4 "Action": "*",
5 "Resource": "*",
6 "Condition": {
7 "StringEquals": {
8 "aws:ResourceTag/agent-visibility": "restricted"
9 }
10 }
11}Be honest about what that buys. StringEquals doesn't match when the key is absent, so an untagged resource is not denied. Tag-based deny is a policy control that fails open on the exact resource someone forgot to tag.
What Describe returns is still the payload
The narrow policy above is not a data-free policy. DescribeInstances returns private IPs, AMI IDs, instance profile ARNs and every tag on the instance. DescribeSecurityGroups returns your ingress topology. DescribeDBInstances returns endpoint hostnames and master usernames.
None of that is a secret individually. In aggregate it is a network diagram, an account inventory, and a naming convention — and it leaves your account on every tool call. Scope by region and by account for that reason as much as for the blast radius. A per-environment role that only ever sees staging is worth more than a beautifully written production policy.
Deliver the credential, don't mint one
A long-lived IAM user access key for an agent is the worst available option, and it's the one that shows up in practice, because it's the only one that works identically on a laptop and in a cluster. It ends up in an env var, in a container image, in a .env someone pasted into a chat window, and it never expires.
On EKS, use EKS Pod Identity for new workloads — a Pod Identity association maps a service account to a role with pods.eks.amazonaws.com as the trust principal, no OIDC provider setup and no ServiceAccount annotation. Since June 2025 it supports cross-account access via a targetRoleArn parameter, where EKS chains from the local role into the target account's role. IRSA remains fully supported and is the right answer on existing clusters; the mechanics of both are in AWS IAM: Roles, Policies, Permission Boundaries, and IRSA for EKS.
Off Kubernetes, the equivalent is an instance profile or a task role. The rule is the same: the credential is delivered by the platform, expires on its own, and never exists as a string anyone can copy.
For a per-task ceiling narrower than the role, pass a session policy on the AssumeRole call. Effective permissions are the intersection, so an agent invocation that only needs load balancer data can hand itself an ELB-only session and hold that ceiling for the length of the task.
Verify the ceiling in CI, not in the console
The system prompt saying "you only have read access" is documentation. The assertion is that the policy cannot grant it.
IAM Access Analyzer's custom policy checks are built for exactly this and run against a policy document, so they work before deployment:
1# Does this policy grant anything on the forbidden list?
2aws accessanalyzer check-access-not-granted \
3 --policy-type IDENTITY_POLICY \
4 --policy-document file://agent-inspect.json \
5 --access '[{"actions":["secretsmanager:GetSecretValue","s3:GetObject","sts:AssumeRole"]}]'
6
7# Does this change widen access versus the approved version?
8aws accessanalyzer check-no-new-access \
9 --policy-type IDENTITY_POLICY \
10 --new-policy-document file://agent-inspect.json \
11 --existing-policy-document file://agent-inspect.approved.json
12
13# Grammar and best-practice findings
14aws accessanalyzer validate-policy \
15 --policy-type IDENTITY_POLICY \
16 --policy-document file://agent-inspect.jsoncheck-no-new-access is the one to wire into the pull request. It turns "did this diff widen the agent's reach?" from a code review judgement call into a build failure.
CloudTrail is your egress log, not your change log
Here the same inversion applies as in Kubernetes: for an agent, the read is the event worth recording, because the read is the moment data crossed the boundary.
Two AWS specifics change how you configure that:
Read-only management events are logged by default. A trail created without explicit event selectors logs all read and write management events. DescribeInstances, GetSecretValue, DescribeSecret — all there, no configuration required.
Data events are not logged by default, and cost extra. s3:GetObject and lambda:Invoke are data events. If your agent reads an S3 object, that read leaves no trace at all unless you have explicitly enabled S3 data events on the trail. The most sensitive read in the account is the one that's invisible by default. That is a second, independent reason to deny s3:GetObject outright rather than scope it — you can't audit what you can't see. (CloudWatch vs CloudTrail covers which service records what.)
Then alert on denials. Every AccessDenied from the agent role is either a bug in your allow list or the agent being steered somewhere it shouldn't go, and both are worth a page:
1{
2 "source": ["aws.secretsmanager", "aws.sts", "aws.s3", "aws.ec2"],
3 "detail-type": ["AWS API Call via CloudTrail"],
4 "detail": {
5 "userIdentity": {
6 "sessionContext": {
7 "sessionIssuer": {
8 "arn": ["arn:aws:iam::012345678901:role/InfraInspectAgent"]
9 }
10 }
11 },
12 "errorCode": ["AccessDenied", "UnauthorizedOperation"]
13 }
14}One trap in that rule, and it silently eats most of the value: EventBridge does not match read-only management events — anything named Describe*, Get* or List* — against a rule in the normal ENABLED state. Since an inspection agent's denials are almost entirely Describe/Get calls, the rule as written would fire on nearly nothing. Create it with the state that opts those events in:
aws events put-rule --name AgentAccessDenied \
--event-bus-name default \
--state ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS \
--event-pattern file://agent-denied.jsonThat state is only settable through the CLI and CloudFormation, not the console.
What this does not fix
It does nothing about authorised reads. This is the honest limit and it's a big one. If DescribeDBInstances is allowed and your database endpoints are sensitive, a perfect policy is still an exfiltration path with excellent paperwork. IAM controls reachability. It has no opinion about what happens to the bytes after they're returned, and the provider's retention policy is not in your account.
It does nothing about the agent being wrong. A confident, incorrect diagnosis that sends an engineer to reboot the wrong instance causes an outage the policy never touched. Permissions are containment, not correctness — the same conclusion reached in Build an AI Kubernetes Troubleshooting Agent.
It does not survive scope creep by itself. The request to add one write action will arrive framed as small. Treat it as a design change, not a policy edit: the answer is an approval-gated path with a human in the loop, not a new statement in the allow list. The Deny policy exists so that when someone does widen the grant without asking, the floor holds.
And if the agent genuinely needs a secret to do its job, it should be handed a scoped, rotatable credential through a path built for that — see AWS Secrets Manager and Parameter Store — not given the permission to go and fetch every other one on the way.
Was this article helpful?
Be the first to rate this article
Related Topics
Found this useful? Share it.


