Back to blog
AWSBest PracticesCloud SecurityCompute & ContainersIdentity & Access

No IAM Role Attached: Why Your EC2 Instances Need Instance Profiles

Learn why EC2 instances without an IAM role lead to leaked static keys, and how to attach least-privilege instance profiles, enforce IMDSv2, and gate it in CI.

TL;DR

This check flags EC2 instances that have no IAM instance profile attached, which forces applications to fall back on long-lived access keys. Attach an instance profile with a least-privilege role so your apps get temporary, auto-rotating credentials from the instance metadata service.

An EC2 instance without an IAM role is a small detail that tends to cause big problems later. The instance still runs fine, your app still serves traffic, and nothing looks broken. But the moment that application needs to talk to S3, DynamoDB, Secrets Manager, or any other AWS service, someone has to make a decision about credentials. Without a role attached, that decision almost always lands on the worst option: hardcoded access keys.

The ec2_no_iam_role check catches this gap before it turns into a credential leak. Here is what it detects, why it matters, and how to fix it properly.


What this check detects

The check inspects every EC2 instance in your account and reports any instance that has no IAM instance profile associated with it. In AWS terms, an instance profile is the container that holds an IAM role and attaches it to an EC2 instance. When a profile is present, applications running on the instance can request temporary credentials from the instance metadata service (IMDS) instead of carrying their own keys.

Note: An instance profile and an IAM role are related but not identical. The role defines the permissions. The instance profile is the wrapper that lets EC2 assume that role. When you attach a role to an instance through the console, AWS creates the instance profile for you behind the scenes.

A flagged instance means one of two things: either the workload genuinely does not need AWS API access (uncommon), or it does need access and is getting those permissions some other way, usually through static credentials baked into the AMI, environment variables, or a config file. That second case is the real concern.


Why it matters

The risk here is not the missing role itself. It is what people do to compensate for it.

Static keys are the default fallback, and they leak

When an application needs to call AWS and there is no role, the path of least resistance is to generate an IAM user, create an access key, and drop it somewhere the app can read it. Those keys are long-lived. They do not rotate unless someone remembers to rotate them, and they end up in places they should never be:

  • Committed to a Git repository (public or private)
  • Stored in plaintext environment variables visible to any process on the box
  • Baked into a Docker image layer that gets pushed to a registry
  • Written to a .env file that ships with a deployment

A leaked static key is one of the most common ways AWS accounts get compromised. Attackers scan public repos for key patterns constantly, and a working key gives them whatever the associated user can do, indefinitely, until someone notices.

Danger: A leaked long-lived access key does not expire on its own. If it lands in a public repo and grants broad permissions, an attacker can spin up expensive resources, exfiltrate data, or pivot deeper into your account within minutes. IAM roles issue temporary credentials that expire automatically, which shrinks this window dramatically.

You lose attribution and rotation for free

Roles give you temporary credentials that AWS rotates automatically through IMDS. CloudTrail logs the role session, so you can see which instance made which call. With shared static keys, multiple instances or even multiple services may use the same credential, and your audit trail collapses into a single unhelpful identity.

Real-world scenario

A team launches a batch processing fleet that reads from S3 and writes to a database. Nobody attaches a role at launch because the AMI already has a ~/.aws/credentials file from an earlier prototype. Six months later that AMI gets shared with a partner account during a debugging session. The embedded key, which has s3:* across the account, is now in someone else's hands. None of this happens if the instance pulls scoped, temporary credentials from an attached role.


How to fix it

The fix is to create an IAM role with only the permissions the workload needs, wrap it in an instance profile, and attach it to the instance. You can do this on a running instance without stopping it.

Step 1: Create the role and trust policy

Create a trust policy that lets EC2 assume the role:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "ec2.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}

Save it as trust-policy.json, then create the role:

aws iam create-role \
  --role-name app-s3-read-role \
  --assume-role-policy-document file://trust-policy.json

Step 2: Attach a least-privilege permissions policy

Grant only what the application actually uses. This example allows read access to one bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::my-app-data",
        "arn:aws:s3:::my-app-data/*"
      ]
    }
  ]
}
aws iam put-role-policy \
  --role-name app-s3-read-role \
  --policy-name s3-read-access \
  --policy-document file://s3-read-policy.json

Step 3: Create the instance profile and add the role

aws iam create-instance-profile \
  --instance-profile-name app-s3-read-profile

aws iam add-role-to-instance-profile \
  --instance-profile-name app-s3-read-profile \
  --role-name app-s3-read-role

Step 4: Attach the profile to the instance

aws ec2 associate-iam-instance-profile \
  --instance-id i-0abc123def4567890 \
  --iam-instance-profile Name=app-s3-read-profile

Warning: IAM is eventually consistent. After creating an instance profile, it can take a few seconds before it is available to attach. If the associate command fails, wait and retry rather than assuming the profile was created incorrectly.

Step 5: Verify and remove the old static keys

Confirm the instance can see the role through IMDS. Prefer IMDSv2 with a token:

TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/

You should see the role name returned. Once the app is confirmed using the role, delete any leftover static credentials and deactivate the old access key in IAM.

Tip: The AWS SDKs and CLI follow a default credential chain that checks IMDS automatically. In most cases you do not need to change application code at all. Removing the ~/.aws/credentials file and the AWS_ACCESS_KEY_ID environment variable is enough to make the SDK fall through to the instance role.


Doing it with infrastructure as code

Console fixes drift. Define the role, profile, and association in your IaC so every instance launches correctly. Here is the Terraform version:

resource "aws_iam_role" "app" {
  name = "app-s3-read-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "app_s3" {
  name = "s3-read-access"
  role = aws_iam_role.app.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:GetObject", "s3:ListBucket"]
      Resource = [
        "arn:aws:s3:::my-app-data",
        "arn:aws:s3:::my-app-data/*"
      ]
    }]
  })
}

resource "aws_iam_instance_profile" "app" {
  name = "app-s3-read-profile"
  role = aws_iam_role.app.name
}

resource "aws_instance" "app" {
  ami                  = "ami-0abc123def4567890"
  instance_type        = "t3.micro"
  iam_instance_profile = aws_iam_instance_profile.app.name

  metadata_options {
    http_tokens = "required" # enforce IMDSv2
  }
}

Notice the metadata_options block requiring IMDSv2. Pairing a role with IMDSv2 closes off the SSRF attack path where a vulnerable web app could be tricked into reading instance credentials over IMDSv1.


How to prevent it from happening again

Catching this once is fine. The goal is to make it impossible to ship an instance without a role going unnoticed.

Gate it in CI/CD with policy as code

Run a policy check against your plan before apply. A Checkov scan catches missing profiles in Terraform:

checkov -d . --check CKV_AWS_290,CKV2_AWS_41

CKV2_AWS_41 specifically verifies that an IAM role is attached to EC2 instances. Wire this into your pipeline so a plan without a role fails the build instead of reaching production.

Enforce with an OPA / Conftest rule

If you use OPA, a simple Rego policy keeps roleless instances out:

deny[msg] {
  resource := input.resource.aws_instance[name]
  not resource.iam_instance_profile
  msg := sprintf("EC2 instance '%s' has no IAM instance profile", [name])
}

Continuous detection

IaC gates only cover resources created through IaC. Instances launched by hand, by autoscaling with a stale launch template, or by another team still slip through. Lensix runs ec2_no_iam_role continuously across your accounts and flags any instance that lacks a profile, so manual drift gets surfaced even when it bypasses your pipeline.

Tip: Bake the instance profile into your launch templates and Auto Scaling groups rather than attaching it per instance. Every instance that scales out inherits the role automatically, and there is no manual step to forget.


Best practices

  • One role per workload, not one role for everything. Sharing a single broad role across unrelated instances defeats the point. Scope each role to the specific services and resources its workload touches.
  • Start from zero permissions and add only what fails. Run the app with a minimal policy, watch for access-denied errors in CloudTrail, and grant exactly those actions. This is far safer than starting with * and trying to trim later.
  • Always require IMDSv2. Set http_tokens = "required" on every instance. This is the single most effective mitigation against credential theft through server-side request forgery.
  • Never use IAM users for service-to-service access. IAM users with access keys are for cases where roles genuinely cannot be used. On EC2, roles always can be.
  • Audit role permissions periodically. Use IAM Access Analyzer to find unused permissions and tighten policies over time. Roles tend to accumulate access and rarely lose it without deliberate cleanup.
  • If an instance truly needs no AWS access, document that. Some instances legitimately make no AWS API calls. Tag them clearly so the absence of a role reads as intentional rather than an oversight.

Attaching a role takes a couple of minutes and removes an entire category of credential risk. The hard part is not the fix, it is making sure no instance ever ships without one. Get the role into your launch templates, gate it in CI, and let continuous scanning catch the rest.