IAM Is Where AWS Breaches Start: Seven Years of Incidents, Four Recurring Patterns
Every major AWS-adjacent breach over the past seven years shares at least one of four IAM conditions. All four are visible in Terraform before deployment. This post explains each pattern, its public incident context, and the Terraform fix.
According to the Verizon 2025 Data Breach Investigations Report, 15% of breaches involve cloud misconfiguration — and that number has risen every year since cloud infrastructure became the default deployment target. What the aggregate number does not show is the pattern inside it: the overwhelming majority of cloud misconfigurations that result in a breach originate in IAM.
Not in encryption. Not in network topology. IAM.
This is not because IAM is uniquely complex. It is because IAM is where every other security control ultimately depends. A correctly encrypted S3 bucket with an overly permissive bucket policy is effectively unencrypted for the principals that policy covers. A network ACL configured correctly but attached to a role that any EC2 instance can assume provides no real boundary. IAM is the permission layer underneath every other control — and when it fails, everything that depended on it fails with it.
This post examines four IAM patterns that appear consistently across major AWS-adjacent incidents since 2019. All four are well-known. All four exist in production Terraform at scale. And all four are fixable before deployment — which is when they are cheapest to fix.
The four recurring Terraform IAM patterns
IMDSv1 enabled on EC2 with an overly permissive instance role
Long-lived static IAM credentials in source control or CI environment variables
Wildcard IAM permissions on Lambda execution roles
No MFA enforcement on IAM users with programmatic access
Pattern 1 — IMDSv1 + overly permissive EC2 instance role
The incident context
In 2019, a server-side request forgery (SSRF) vulnerability in a web application allowed an attacker to issue HTTP requests from the EC2 instance running it. The target of those requests was the EC2 Instance Metadata Service at169.254.169.254. Because the metadata service was running in its default mode — IMDSv1, which requires no authentication — the attacker received temporary AWS credentials for the EC2 instance’s IAM role in the response body.
The IAM role attached to that instance hads3:GetObjectands3:ListBucketson Resource: "*" — covering every S3 bucket in the AWS account. Those credentials provided read access to the data they were ultimately used to access. The facts above are drawn from the DOJ criminal complaint and the company’s own SEC filings.
The SSRF vulnerability in the application is what made the attack possible. But SSRF vulnerabilities exist across an enormous range of codebases — web application firewalls, image processors, webhook handlers, proxy services. The Terraform pattern that determined the blast radius when one was exploited is this: an EC2 instance without IMDSv2 enforcement, attached to a role with broader permissions than the application required.
The Terraform pattern
Neither Terraform attribute is immediately visible as dangerous in isolation. An EC2 instance without a metadata_options block is standard — it is what Terraform produces if you follow the basic provider documentation. An IAM policy with Resource: "*"on specific actions (not wildcards) passes most linter checks. Together, they create the condition where one application vulnerability unlocks an entire S3 account.
# ec2.tf — Application server with default metadata settings# and account-wide S3 read access.## If the application has any SSRF vulnerability, an attacker can# issue HTTP requests from the EC2 instance to the IMDSv1 endpoint# (169.254.169.254) without authentication and receive role credentials.# Those credentials provide access to every S3 bucket in the account.resource "aws_instance" "app" { ami = "ami-0abcdef1234567890" instance_type = "t3.medium" iam_instance_profile = aws_iam_instance_profile.app_profile.name # No metadata_options block — IMDSv1 is the default}resource "aws_iam_role_policy" "app_s3" { name = "app-s3-access" role = aws_iam_role.app_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["s3:GetObject", "s3:ListBucket", "s3:ListAllMyBuckets"] Resource = "*" # read access to all buckets in the account }] })}SSRF → IMDSv1 → role credentials → all S3 buckets. Each element passes linting individually.
The fix
Two changes break the attack chain. IMDSv2 requires that the metadata request include a session token obtained through a PUT request — which SSRF vulnerabilities cannot issue (they can only follow redirects, not initiate PUT requests with specific headers). Even if the application vulnerability still exists, the credential retrieval step fails.
Scoping the IAM role to the specific bucket and path the application actually reads limits the blast radius if the IMDSv2 enforcement is somehow bypassed or if credentials are obtained through another vector.
# ec2.tf — IMDSv2 required; role scoped to the specific bucket (WAF SEC 2 + SEC 6)resource "aws_instance" "app" { ami = "ami-0abcdef1234567890" instance_type = "t3.medium" iam_instance_profile = aws_iam_instance_profile.app_profile.name metadata_options { http_endpoint = "enabled" http_tokens = "required" # IMDSv2 only; breaks SSRF→credential chain http_put_response_hop_limit = 1 }}resource "aws_iam_role_policy" "app_s3" { name = "app-s3-access" role = aws_iam_role.app_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["s3:GetObject"] Resource = "${aws_s3_bucket.app_data.arn}/app-data/*" }] })}IMDSv2 breaks the SSRF→metadata chain; role scoped to a single bucket path
A Checkov rule (CKV_AWS_79) covers the IMDSv2 piece. For a complete walkthrough of this and four other Terraform patterns that fail the Well-Architected Security pillar, see The Five Terraform Misconfigurations That Fail an AWS Well-Architected Security Review.
Pattern 2 — Long-lived static credentials in source control or CI
The incident context
Two separate incidents illustrate this pattern from different angles.
In the LastPass breach (2022), disclosed across a series of security bulletins through early 2023, a threat actor compromised a DevOps engineer’s personal computer through a vulnerability in a third-party media application. That computer had access to a set of static AWS IAM credentials. Those credentials had privileges sufficient to access encrypted customer vault backups — and, because they were long-lived static keys with no expiry, they had been valid and unchanged for long enough to be present on a personal device without any active rotation mechanism. Details are drawn from LastPass’s own security bulletins published in November 2022, December 2022, and March 2023.
In the Sisense compromise (2024), addressed in CISA Alert AA24-101A, credentials including AWS access keys were found in a self-managed GitLab repository. When the repository or the instance hosting it was accessed by the threat actor, those credentials provided direct access to cloud storage containing customer data. The alert notified organisations that use Sisense to rotate any credentials that the product had access to.
The connecting thread: static, long-lived IAM access keys are credentials that exist in files, in environment variables, in developer toolchains, and in version control history. Unlike short-lived STS credentials (which expire in minutes to hours), a static key remains valid until it is explicitly rotated or deleted — which often does not happen until after a breach.
The Terraform pattern
Creating an IAM user with a static access key is the default pattern for granting CI pipelines AWS access. It is what most Terraform tutorials demonstrate. It is also what stores the secret access key value in Terraform state — which itself must be stored somewhere, encrypted or not.
# ci.tf — IAM user with static access key for the CI pipeline.## Static keys do not expire. If committed to version control, stored# in a CI platform without secret scanning, or present in a developer's# environment, they remain valid until manually deleted or rotated.# No automated rotation is configured here.resource "aws_iam_user" "ci_deployer" { name = "ci-deployer"}resource "aws_iam_access_key" "ci_deployer" { user = aws_iam_user.ci_deployer.name # No pgp_key for encrypted output. Key will be in Terraform state in plaintext.}resource "aws_iam_user_policy" "ci_deployer" { name = "ci-deployer-policy" user = aws_iam_user.ci_deployer.name policy = jsonencode({ Statement = [{ Effect = "Allow" Action = ["s3:*", "lambda:*", "ecs:*"] Resource = "*" }] })}Static key stored in Terraform state; no expiry; broad permissions. Valid until manually deleted.
The fix
OIDC federation eliminates static keys from the CI picture entirely. The CI platform authenticates with AWS using a signed JWT token issued by the CI platform’s own identity provider (GitHub, GitLab, CircleCI all support this). AWS STS verifies the token against the registered OIDC provider and issues short-lived credentials — typically valid for one hour — that are scoped to the trust policy conditions.
The Condition block on the assume-role policy is critical: without it, any GitHub Actions workflow in any repository could assume the role. Scoping to a specific repository and branch via thesub claim limits the role to exactly the deployment workflow that needs it.
# ci.tf — OIDC federation for GitHub Actions; no static keys created (WAF SEC 3)resource "aws_iam_openid_connect_provider" "github" { url = "https://token.actions.githubusercontent.com" client_id_list = ["sts.amazonaws.com"] thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]}resource "aws_iam_role" "github_deploy" { name = "github-actions-deploy" assume_role_policy = jsonencode({ Statement = [{ Effect = "Allow" Principal = { Federated = aws_iam_openid_connect_provider.github.arn } Action = "sts:AssumeRoleWithWebIdentity" Condition = { StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" "token.actions.githubusercontent.com:sub" = "repo:acme-org/app:ref:refs/heads/main" } } }] })}resource "aws_iam_role_policy" "github_deploy" { name = "deploy-policy" role = aws_iam_role.github_deploy.id policy = jsonencode({ Statement = [ { Effect = "Allow" Action = ["s3:PutObject", "s3:GetObject"] Resource = "${aws_s3_bucket.artifacts.arn}/*" }, { Effect = "Allow" Action = ["lambda:UpdateFunctionCode"] Resource = aws_lambda_function.app.arn } ] })}# No aws_iam_access_key resource — credentials are short-lived tokens from STS.OIDC federation — no static keys created, no key in Terraform state, credentials expire in 1 hour
Pattern 3 — Wildcard IAM permissions on Lambda execution roles
Why this pattern is endemic
Lambda functions need IAM permissions to interact with other AWS services. The fastest way to get a Lambda working is to give its execution role broad permissions, deploy, iterate on the function logic, and plan to “tighten the permissions later.” Later rarely comes.
The blast radius of an overly permissive Lambda execution role depends on what the Lambda can reach with those permissions. For a Lambda that can call any AWS API on any resource, a code injection vulnerability — a deserialization bug, an unsafe eval, an SSRF in Lambda that reaches the metadata service — translates directly to full account access.
Checkov’s CKV_AWS_40 checks for the AdministratorAccess managed policy attached to a Lambda role. It does not check for Action: "*" in inline policies — a common alternative that provides the same access through a different mechanism.
The Terraform pattern
# lambda.tf — Execution role with wildcard Action.## CKV_AWS_40 (Checkov) checks for the AdministratorAccess managed policy.# It does not catch inline policies where Action = "*" is used directly.# The blast radius: if this Lambda is exploited, the attacker gains# full AWS API access using the role's credentials.resource "aws_iam_role_policy" "processor_policy" { name = "processor-inline" role = aws_iam_role.processor_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = "*" # full AWS access — not caught by CKV_AWS_40 Resource = "*" }] })}Action: "*" in an inline policy gives full AWS access; CKV_AWS_40 does not catch this form
The fix
The correct approach is to enumerate exactly which API calls the Lambda function makes and grant only those — scoped to the specific resources the function interacts with. This is more work than a wildcard, but it can be done once at the time the function is deployed and updated when the function’s dependencies change.
The three statements in the fixed policy below cover what a typical SQS-triggered Lambda that writes to DynamoDB and emits logs actually requires. Nothing more.
# lambda.tf — Scoped to the three specific API calls this function makes (WAF SEC 2)resource "aws_iam_role_policy" "processor_policy" { name = "processor-inline" role = aws_iam_role.processor_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"] Resource = aws_sqs_queue.input_queue.arn }, { Effect = "Allow" Action = ["dynamodb:PutItem", "dynamodb:UpdateItem"] Resource = aws_dynamodb_table.results.arn }, { Effect = "Allow" Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"] Resource = "arn:aws:logs:*:*:log-group:/aws/lambda/${var.function_name}:*" } ] })}Three statements, each scoped to the specific resource ARN the function interacts with
AWS publishes the full list of IAM actions per service in the Service Authorization Reference. AWS IAM Access Analyzer can also generate a least-privilege policy from CloudTrail events after a function has been running in a non-production environment — useful for functions with complex service dependencies.
Pattern 4 — No MFA enforcement on IAM users with programmatic access
The incident context
In January 2025, security researchers at Halcyon documented a threat actor they named Codefinger — the first publicly documented ransomware campaign using AWS’s own encryption infrastructure against its customers.
The attack mechanism: the threat actors obtained valid AWS IAM access keys (through means consistent with credential exposure, though the initial access method has not been fully disclosed publicly). They then used those keys to re-encrypt victim S3 objects using SSE-C — Server-Side Encryption with Customer-Provided Keys. In SSE-C, the encryption key is provided by the caller and is never stored by AWS. The attackers provided a key they controlled; without that key, neither the victim nor AWS can decrypt the objects.
The IAM condition that enabled this: the access keys had sufficient S3 permissions and no MFA requirement. An attacker with the key alone — no second factor — could perform the full re-encryption. The AWS Security Bulletin issued at the time recommended restricting s3:PutObject permissions and auditing IAM credentials.
The pattern is not specific to SSE-C: any high-privilege IAM user without MFA enforcement is a single credential away from full exploitation.
The Terraform pattern
Terraform has no built-in mechanism for enforcing MFA — the enforcement lives in IAM policy conditions attached to the user or their group. Without explicit MFA condition policies, IAM users with static access keys can perform any permitted action with the key alone.
# iam.tf — IAM user with programmatic access and no MFA requirement.## An access key issued to this user is valid without MFA, regardless of# the actions it can perform. If the key is exposed — through credential# stuffing, phishing, or a compromised developer workstation — an attacker# can use it immediately, with no second factor to stop them.resource "aws_iam_user" "ops_admin" { name = "ops-admin"}resource "aws_iam_access_key" "ops_admin" { user = aws_iam_user.ops_admin.name}resource "aws_iam_user_policy_attachment" "ops_admin" { user = aws_iam_user.ops_admin.name policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess" # No MFA enforcement condition anywhere in the policy chain}PowerUserAccess with no MFA condition — access key alone is sufficient for full exploitation
The fix
A deny-without-MFA policy, applied to the user or their group, blocks any API call that is not on the MFA-enrollment exception list unlessaws:MultiFactorAuthPresentis true in the session. The key detail is theBoolIfExistscondition operator — it applies the condition even when the key is not present in the request context, which is the case for programmatic access without an MFA session.
# iam.tf — Deny-without-MFA policy applied to the user (WAF SEC 3)resource "aws_iam_policy" "require_mfa" { name = "require-mfa-before-sensitive-actions" description = "Denies all actions except MFA enrollment if MFA is not present" policy = jsonencode({ Version = "2012-10-17" Statement = [{ Sid = "DenyWithoutMFA" Effect = "Deny" NotAction = [ "iam:CreateVirtualMFADevice", "iam:EnableMFADevice", "iam:GetUser", "iam:ListMFADevices", "iam:ListVirtualMFADevices", "iam:ResyncMFADevice", "sts:GetSessionToken", ] Resource = "*" Condition = { BoolIfExists = { "aws:MultiFactorAuthPresent" = "false" } } }] })}resource "aws_iam_user_policy_attachment" "ops_admin_mfa" { user = aws_iam_user.ops_admin.name policy_arn = aws_iam_policy.require_mfa.arn}resource "aws_iam_user_policy_attachment" "ops_admin_poweruser" { user = aws_iam_user.ops_admin.name policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"}# Note: for new systems, prefer OIDC federation over IAM users entirely.# MFA enforcement is a mitigation for existing IAM users, not a recommendation# to keep creating them.DenyWithoutMFA policy blocks all actions unless the session was authenticated with MFA
What GuardDuty catches — and what it doesn’t catch before deploy
GuardDuty is a runtime threat detection service. It analyses CloudTrail events, VPC Flow Logs, and DNS logs for anomalous behaviour patterns — credentials used from an IP address not previously seen for that account, unusual S3 data exfiltration rates, IAM enumeration from a Tor exit node.
GuardDuty is genuinely useful: it is one of the cheapest per-finding signals available for active credential abuse and lateral movement. Enable it.
What GuardDuty cannot do: evaluate Terraform before deployment. GuardDuty begins producing signals only after a resource is deployed and generating CloudTrail events. An overly permissive Lambda execution role, an IAM user without MFA enforcement, or an EC2 instance with IMDSv1 enabled — none of these produce any GuardDuty finding simply by existing. They produce findings only when they are actively exploited.
The Codefinger attack was caught by security researchers studying victim reports — not by GuardDuty findings surfaced before the re-encryption occurred. The detection-after-exploitation model is the fundamental limitation of any runtime signal when the risk lies in a configuration.
| Capability | GuardDuty | Pre-deploy IAM review |
|---|---|---|
| Credentials used from new geography | ✓ Yes | ✗ No |
| S3 data exfiltration at unusual rate | ✓ Yes | ✗ No |
| IAM enumeration from Tor exit node | ✓ Yes | ✗ No |
| Overly permissive role before it is exploited | ✗ No | ✓ Yes |
| Static credentials before they are leaked | ✗ No | ✓ Yes |
| Wildcard IAM on Lambda execution role | ✗ No | ✓ Yes |
| Missing MFA enforcement on IAM user | ✗ No | ✓ Yes |
Enable GuardDuty. But treat pre-deployment IAM review as a distinct and necessary layer — not a substitute for runtime detection, and not something that runtime detection makes redundant.
The shared responsibility line for IAM
The AWS Shared Responsibility Model divides security into two domains: security of the cloud (AWS’s responsibility — physical facilities, hardware, hypervisor, IAM service availability) and security in the cloud (your responsibility — what you configure in IAM, which principals you trust, what permissions you grant).
For IAM specifically, this means: AWS guarantees that the IAM service enforces the policies you write, that OIDC token verification is correct, and that the metadata service behaves as documented. AWS does not guarantee that the policies you write are appropriate for your workload, that the roles you create are scoped correctly, or that the IAM users you create have MFA enforced.
Every pattern in this post is in the customer’s half of the responsibility model. Which means it is configurable in Terraform, reviewable before deployment, and fixable before any attacker has had a chance to exploit it.
This is the operational conclusion from seven years of incidents: the conditions that determined the blast radius of each breach were in place in Terraform (or its predecessor configurations) before the breach occurred. The SSRF vulnerability, the compromised personal machine, the phishing email — those were the triggers. The IAM configuration was what determined whether triggering those vulnerabilities resulted in a critical incident or a near-miss.
For a broader treatment of the six-step architecture review process and how IAM review fits within the full Well-Architected Security pillar evaluation, see the Terraform Architecture Review: A Complete Guide.
Frequently asked questions
What is the most common IAM misconfiguration in Terraform?↓
Overly broad permissions — roles and policies that grant more actions or cover more resources than the workload requires. This ranges from explicit wildcards (Action: "*") to specific action lists that include write or delete permissions for functions that only need read access. It appears consistently because the path of least resistance when writing Terraform is to broaden scope until something works, then never revisit.
Does Checkov catch wildcard IAM permissions?↓
Partially. CKV_AWS_40 checks whether the AdministratorAccess managed policy is attached. CKV_AWS_289 checks for wildcard actions in inline policies. Neither checks for overly broad specific-action lists that do not use wildcards — a role with 15 specific actions where the workload needs 3 passes all Checkov IAM rules. Architectural review evaluates whether permissions are appropriate for the workload, not just whether they contain wildcards.
How do I rotate IAM access keys managed by Terraform?↓
Terraform manages access key lifecycle: creating a new aws_iam_access_key resource and deleting the old one constitutes rotation, but this stores the secret key in Terraform state in plaintext. The better approach is to eliminate IAM users entirely for machine workloads and use OIDC federation instead. OIDC tokens are short-lived (typically 1 hour) and scoped per repository and branch, which removes the rotation problem entirely.
What is OIDC federation and why does it replace IAM users for CI pipelines?↓
OIDC federation lets a CI platform (GitHub, GitLab, CircleCI) present a signed JWT token to AWS STS in exchange for short-lived IAM role credentials. No access key is created or stored — the CI pipeline authenticates with a token that AWS verifies against the OIDC provider. The resulting credentials expire in 1 hour by default and can be scoped to a specific repository and branch via the sub claim condition. This eliminates static key storage, rotation management, and the risk of key exposure through source control or CI environment variable leaks.
Can GuardDuty detect IAM misconfigurations before deployment?↓
No. GuardDuty is a runtime detection service — it analyses CloudTrail events for anomalous behaviour after resources are deployed. It can detect that an IAM credential is being used from an unusual location or that S3 data is being exfiltrated at an unusual rate. It cannot detect that a Terraform resource is about to be deployed with an overly permissive IAM role or no MFA enforcement. Pre-deployment IAM review requires static analysis of the Terraform itself.
What was the Codefinger ransomware attack and how did IAM enable it?↓
Codefinger, documented by security researchers in January 2025, was the first publicly documented ransomware campaign weaponising AWS SSE-C. Threat actors obtained valid IAM access keys and used them to re-encrypt victim S3 objects using SSE-C with a key the attackers controlled. Because AWS never stores SSE-C keys, recovery without the attacker's key is impossible. The absence of MFA on the compromised IAM credentials meant the access keys alone were sufficient to carry out the full attack.
Review Your Terraform IAM Configuration
Upload your Terraform and get a Well-Architected Security review covering IAM patterns, blast radius mapping, and prioritised findings — with a PDF report you can share with your team and stakeholders.
Findings include WAF control references, severity by workload context, and HCL remediation examples.