An IAM role that was scoped for a single Lambda function nine months ago rarely stays that way. Someone adds a wildcard action to unblock a deploy at 11pm, a debugging session leaves broad EC2 permissions attached, and a role that started with four actions ends up with two hundred, most of which nobody on the current team can explain. Over-permissioned IAM roles are rarely the result of one bad decision. They are the accumulated residue of dozens of individually reasonable ones, and finding them before an attacker does means treating permission creep as a scheduled audit rather than a one-time cleanup.
How wildcard policies accumulate: copy-paste, debugging, and forgotten pilots
Permission sprawl has a small number of repeat causes, and they show up in almost every account that has been live for more than a year:
- A wildcard resource is added to a policy statement to unblock one ARN that changes between environments, and nobody narrows it back down once the deploy succeeds.
- A managed policy such as AdministratorAccess or PowerUserAccess gets attached “temporarily” during an incident, and detaching it afterward is nobody’s assigned task.
- A role created for a proof of concept outlives the project, because deleting infrastructure feels riskier than leaving it alone.
- New roles are copied from an existing role that “already works” instead of being defined from the task at hand, so every fresh service inherits the sins of its predecessor.
- CI/CD service roles accumulate the union of every pipeline they have ever run, since removing a permission carries the risk of breaking a green build and nobody wants to own that regression.
None of these require malice or even carelessness in the moment. Each one is a locally rational shortcut under time pressure. The problem is that IAM has no natural decay function: a permission granted in March is exactly as active in December as the day it was added, whether or not anything has called it since.
Using last-accessed data to separate needed permissions from decorative ones
The fix is to stop guessing and start measuring. AWS IAM Access Advisor tracks, per service, the last time a principal actually attempted to use it, and for a growing list of services it now tracks this at the individual action level rather than just the service level. Pulling it programmatically looks like this:
- Kick off the job: aws iam generate-service-last-accessed-details –arn arn:aws:iam::111122223333:role/data-pipeline-role –granularity ACTION_LEVEL
- Retrieve results once the job finishes: aws iam get-service-last-accessed-details –job-id <job-id>
A reasonable default threshold is a rolling 90-day window, but treat it as a starting point, not a rule. Before removing anything flagged unused, check for monthly close-of-books jobs, quarterly compliance exports, and annual disaster-recovery drills that may only fire a handful of times a year and would otherwise get deleted by mistake. IAM Access Analyzer’s unused-access finding type automates a version of this same check across an entire account or organization, surfacing unused roles, unused access keys, and unused permissions within existing policies without requiring a separate script for each role.
| Signal | Where to get it | What it tells you |
|---|---|---|
| Action-level last accessed | generate-service-last-accessed-details with ACTION_LEVEL granularity | Which specific API calls a principal has actually made, not just which services |
| Unused access finding | IAM Access Analyzer, unused access analyzer type | Roles, users, and keys with no activity inside a configured lookback window |
| CloudTrail management events | Athena query over CloudTrail Lake or archived logs | Ground truth for every call, its timestamp, and the identity that made it |
| Policy simulation | simulate-principal-policy | Whether a proposed, trimmed policy still allows the calls the application is known to need |
Role chaining and PassRole: the privilege escalation paths scanners miss
A scanner that only checks whether AdministratorAccess is attached directly to a role will clear most accounts as fine, and that is exactly the blind spot that matters. The more common escalation path runs through iam:PassRole, an action that lets a principal hand a role over to an AWS service rather than assume it directly. On its own it looks harmless. Combined with the ability to create a resource that runs code, it is a direct route to full account compromise.
A concrete escalation chain
Consider a developer role that carries iam:PassRole scoped to Resource “*”, plus lambda:CreateFunction and lambda:InvokeFunction for legitimate day-to-day work. Somewhere else in the account sits an old machine-learning pipeline role that was never cleaned up and still carries AdministratorAccess from an earlier debugging session. The chain looks like this:
- Create a new Lambda function and pass it the old ML pipeline’s execution role instead of a scoped one.
- Invoke the function. Code running inside it now holds the credentials of the administrator-level role via the Lambda execution environment.
- Use those credentials to do anything in the account, including creating new IAM users, disabling logging, or exfiltrating data.
The role that got flagged as risky in a naive audit, the developer role, was never itself over-permissioned in isolation. The danger came entirely from what it was allowed to pass to something else. The same pattern shows up with ec2:RunInstances plus iam:PassRole for an instance profile, with glue:CreateDevEndpoint, and with cross-account trust policies whose Principal element is left as a wildcard or as an entire account root rather than a specific role ARN.
The fix is narrower scoping in two places: restrict iam:PassRole to the exact role ARNs a principal legitimately needs to pass, and add an iam:PassedToService condition key so the permission only works for the specific service it was meant for. Then separately audit every trust policy in the account for a wildcard or root-level Principal, since that is the other half of most chaining paths.
Right-sizing a policy without breaking the application that depends on it
Trimming a policy is low-risk in theory and high-anxiety in practice, because the cost of missing one action is a production incident. A sequence that keeps the risk manageable:
- Pull at least 90 days of action-level last-accessed data plus a CloudTrail export of every call the role has actually made, including the resource ARNs involved.
- Draft a candidate policy from that usage. aws accessanalyzer start-policy-generation reads CloudTrail activity for a given principal and proposes a policy scoped to the actions and resources it actually observed.
- Diff the candidate against the current policy, and for every action it drops, confirm there is no quarterly or annual job that would still need it.
- Test the candidate in a non-production copy of the role, or a staging account with equivalent traffic, and watch CloudTrail for AccessDenied events tied to that ARN for at least one full business cycle, including a weekend and a month-end run.
- Promote with a rollback path in hand. IAM keeps up to five policy versions, so reverting is a single aws iam set-default-policy-version call rather than a re-write under pressure.
Generating a least-privilege draft from observed behavior rather than intuition is the same principle behind automated remediation planning for software vulnerabilities: a fix proposed from the specific finding, rather than a generic playbook, is far more likely to hold. Platforms such as SITEY apply that logic to a patch command or configuration change (see AI remediation planning); the same discipline of deriving the fix from the actual evidence rather than a template applies just as well to trimming an IAM policy from an access log.
Guardrails that hold the line: permission boundaries, SCPs, and deny-by-default
Right-sizing individual roles is necessary but it decays the moment someone creates the next one from a bad template. Three controls keep the blast radius capped even when an individual policy is wrong:
Permission boundaries set a ceiling on what a role or user can ever be granted, regardless of what its identity policy says. The effective permission is the intersection of the identity policy and the boundary, so a developer can be allowed to create their own roles for a project without ever being able to grant themselves something outside the boundary. Attach one with aws iam put-role-permissions-boundary.
Service control policies at the AWS Organizations level work the other direction: they never grant anything, only restrict, and they apply account-wide or across an entire organizational unit. Practical SCP targets include denying cloudtrail:StopLogging and cloudtrail:DeleteTrail outside a break-glass role, blocking API calls from outside approved regions, and denying iam:CreateAccessKey except for a small, named set of service accounts.
Deny-by-default account structure means a new account starts with an SCP that blocks everything except an explicit allow list of approved services, and every new role is built from a minimal baseline rather than cloned from whatever role happened to be handy. This inverts the usual failure mode, where permissive is the default and tightening only happens after someone notices.
Auditors evaluating ISO 27001 Annex A.8.2, SOC 2 CC6.3, or PCI DSS Requirement 7 want evidence that access reviews happened on a defined cadence, not just that the current state looks reasonable on the day of the audit. Vulnerability management tooling that keeps a permanent, per-control record of when a finding was opened and closed, such as SITEY, maps each resolved item to the specific clause it satisfies (see compliance mapping) instead of leaving the auditor to reconstruct the history from a spreadsheet that only reflects the most recent pass.
Turning the audit into a repeating job instead of a yearly spreadsheet
A one-time IAM audit starts decaying the moment it finishes, because new roles keep getting created by Terraform modules, one-off Lambda functions, and CI/CD pipelines that nobody schedules a review for. Treat the audit the same way you would treat vulnerability scanning: as a recurring job with a defined cadence and an owner, not an annual project.
A workable pattern is a scheduled job, an EventBridge rule triggering a Lambda or Step Functions workflow, that pulls fresh Access Analyzer unused-access findings on a monthly cycle and diffs them against the previous run. Open a ticket automatically only for a role that crosses the unused threshold on two consecutive runs, which filters out the noise from jobs that fire quarterly rather than monthly. Track time to remediate with the same rigor as a vulnerability SLA: for example, 30 days to trim a non-production role, 14 days for anything with a cross-account trust relationship, and immediate escalation for anything carrying an unscoped iam:PassRole.
Re-testing matters as much here as it does for a patched CVE. Confirming that a trimmed policy still passes the same last-accessed check thirty days later verifies the change actually held, rather than assuming the diff was correct the day it was applied and finding out otherwise during the next incident. Platforms built around a recurring discovery-and-triage cadence, such as SITEY, are a natural place to fold this in: an unused administrator role and an unpatched internet-facing service are both findings that were discovered, validated, and then risked sitting untouched, and putting them on the same schedule (see attack surface management) is what keeps the entitlement side of the audit from reverting to a spreadsheet that only gets opened once a year.
About SITEY
SITEY is an autonomous vulnerability management platform. It discovers, validates, prioritizes, remediates and re-tests vulnerabilities through an eight-phase automated pipeline, unifying output from 17 integrated scanners. SITEY is self-hosted: it runs in your own infrastructure and your findings are stored there. Outbound connections are limited to licence activation and the optional services you enable, such as an AI provider, CVE enrichment and patch catalogues. Pricing is 599 USD per month or 5,999 USD for a perpetual lifetime license. See pricing or how the platform works.