Back to blog
AWSBest PracticesCloud SecurityCompute & ContainersIdentity & Access

Secrets Found in EC2 User Data: Why It's Risky and How to Fix It

Learn why AWS credentials in EC2 user data are a serious risk, how attackers exploit them via SSRF, and how to rotate, remove, and prevent them for good.

TL;DR

This check flags EC2 instances whose user data contains hardcoded AWS access keys or secret keys. User data is readable by anything that can reach the instance metadata endpoint, so a credential there is effectively a leaked credential. Pull the secret out, rotate it immediately, and move to IAM roles or a secrets manager.

User data is one of those features that feels harmless until you realize how exposed it is. It runs your bootstrap script, installs packages, configures agents, and then sits there in plaintext for the life of the instance. Lensix raises ec2_secretsinuserdata when it finds patterns inside that user data matching AWS access key IDs or secret access keys. If the match is real, you have a credential sitting in a spot that is far easier to read than most people assume.


What this check detects

The check inspects the user data attached to each EC2 instance and scans it for strings that look like AWS credentials. The two classic patterns are:

  • Access key IDs, which start with prefixes like AKIA, ASIA, or AROA followed by 16 uppercase alphanumeric characters.
  • Secret access keys, which are 40-character base64-style strings, often pinned to an aws_secret_access_key assignment.

It does not matter whether the credentials are referenced in a shell export, an SDK config block, a Terraform-rendered template, or a cloud-init directive. If the raw bytes match a known credential shape, the instance gets flagged.

Note: User data is whatever blob you pass at launch through --user-data or the launch template. EC2 stores it and serves it back through the instance metadata service at http://169.254.169.254/latest/user-data. It is not encrypted at rest in any special way, and it is not hidden from anyone with access to the instance.


Why it matters

The core problem is reachability. A secret in user data is exposed through several paths, and most of them are easier to abuse than people expect.

The metadata endpoint is local but not private

Any process running on the instance can read user data with a single unauthenticated request:

curl http://169.254.169.254/latest/user-data

That means a compromised web app, a malicious dependency, or an attacker who lands a low-privilege shell can read your AWS credentials without ever touching the AWS API. This is the classic pivot in server-side request forgery (SSRF) attacks: trick the application into fetching the metadata URL, and the credential comes back in the HTTP response.

Danger: A single SSRF bug in an app running on an instance with secrets in user data can hand an attacker long-lived AWS credentials. From there they can enumerate your account, exfiltrate data, or spin up resources for cryptomining. Treat any flagged credential as already compromised.

Anyone with DescribeInstances can read it

User data is exposed through the AWS API too. A principal with ec2:DescribeInstanceAttribute can pull it straight out, no shell access required:

aws ec2 describe-instance-attribute \
  --instance-id i-0123456789abcdef0 \
  --attribute userData \
  --query 'UserData.Value' \
  --output text | base64 -d

That permission is often granted more broadly than people intend. Read-only roles, audit tooling, and overly generous developer policies frequently include it.

It leaks into backups and images

User data follows instances into AMIs and gets captured by snapshots and infrastructure exports. A secret you embedded months ago can resurface in an image you shared with another account or a backup that outlives the original instance.


How to fix it

Remediation has two parts: stop using the leaked credential, then remove it from user data. Do them in that order. The instant a secret is exposed, it has to be treated as burned.

Step 1: Rotate the credential

Find which IAM user the access key belongs to, create a fresh key, update whatever consumes it, then delete the exposed key.

# Identify the key owner if you only have the access key ID
aws iam list-access-keys --user-name app-deploy-user

# Create a replacement key
aws iam create-access-key --user-name app-deploy-user

# After updating consumers, deactivate then delete the leaked key
aws iam update-access-key \
  --user-name app-deploy-user \
  --access-key-id AKIAEXAMPLE12345 \
  --status Inactive

aws iam delete-access-key \
  --user-name app-deploy-user \
  --access-key-id AKIAEXAMPLE12345

Warning: Deactivate the old key before deleting it and watch for failures. If something you forgot about still uses that key, deactivation surfaces the breakage safely while reactivation is still possible. Deleting first removes that safety net.

Step 2: Replace the credential with an instance role

The right fix is almost never a different secret in user data. It is to stop putting a secret there at all. Attach an IAM role to the instance so the SDK pulls temporary, auto-rotating credentials from the metadata service.

# Create a role the instance can assume
aws iam create-role \
  --role-name app-instance-role \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": { "Service": "ec2.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }]
  }'

# Attach only the permissions the app actually needs
aws iam attach-role-policy \
  --role-name app-instance-role \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess

# Wrap it in an instance profile and attach to the instance
aws iam create-instance-profile --instance-profile-name app-instance-profile
aws iam add-role-to-instance-profile \
  --instance-profile-name app-instance-profile \
  --role-name app-instance-role

aws ec2 associate-iam-instance-profile \
  --instance-id i-0123456789abcdef0 \
  --iam-instance-profile Name=app-instance-profile

With a role attached, your application code drops the hardcoded keys entirely. The AWS SDK resolves credentials automatically through the default provider chain.

Step 3: Scrub the user data

You cannot edit user data on a running instance. Stop it, replace the attribute, then start it again. Note this requires a stop/start, which on an EBS-backed instance changes the public IP unless you use an Elastic IP.

Warning: Stopping an instance to modify user data causes downtime and may change its public IPv4 address. Schedule this in a maintenance window, or better, replace the instance from a clean launch template rather than mutating it in place.

aws ec2 stop-instances --instance-id i-0123456789abcdef0
aws ec2 wait instance-stopped --instance-ids i-0123456789abcdef0

# Replace with sanitized user data (no secrets)
aws ec2 modify-instance-attribute \
  --instance-id i-0123456789abcdef0 \
  --attribute userData \
  --value fileb://clean-userdata.txt

aws ec2 start-instances --instance-id i-0123456789abcdef0

For anything you actually need at runtime, like a database password or third-party API token, fetch it at boot from Secrets Manager or SSM Parameter Store instead of baking it in:

#!/bin/bash
DB_PASSWORD=$(aws secretsmanager get-secret-value \
  --secret-id prod/app/db \
  --query SecretString \
  --output text)
# use $DB_PASSWORD without writing it to disk

Tip: Pair the instance role with a scoped resource policy on the secret so only that role can read it. The instance gets exactly one secret it is allowed to fetch, and nothing in user data reveals what the value is.


How to prevent it from happening again

Manual cleanup fixes today's instances. Preventing the next one means catching secrets before they ever reach AWS.

Scan IaC and templates in CI

Most user data starts life as a Terraform template, CloudFormation resource, or cloud-init file in a repo. Run a secret scanner on every pull request so an embedded AKIA key never merges.

# .github/workflows/secret-scan.yml
name: secret-scan
on: [pull_request]
jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}

Gate user data with policy-as-code

Add an OPA or Checkov rule that fails the build if a Terraform aws_instance or launch template has user data matching a credential pattern. Here is the idea in Rego:

package terraform.userdata

deny[msg] {
  resource := input.resource.aws_instance[name]
  regex.match("AKIA[0-9A-Z]{16}", resource.user_data)
  msg := sprintf("aws_instance.%s has an AWS key in user_data", [name])
}

Enforce IMDSv2

Even after you remove secrets, harden the metadata service. IMDSv2 requires a session token and blocks the simplest SSRF exploits that read user data and credentials. Require it at launch:

aws ec2 modify-instance-metadata-options \
  --instance-id i-0123456789abcdef0 \
  --http-tokens required \
  --http-endpoint enabled

In a launch template:

{
  "MetadataOptions": {
    "HttpTokens": "required",
    "HttpEndpoint": "enabled",
    "HttpPutResponseHopLimit": 1
  }
}

Note: Setting HttpPutResponseHopLimit to 1 stops containers behind an extra network hop from reaching the metadata endpoint, which closes a common path for credential theft in containerized workloads.


Best practices

  • Never put long-lived credentials in user data. If code on an instance needs AWS access, give the instance a role. Roles deliver temporary credentials that rotate automatically and never need to be stored.
  • Keep user data minimal. The smaller the bootstrap script, the smaller the blast radius. Pull configuration and secrets at runtime rather than embedding them.
  • Scope IAM roles tightly. Grant only the actions and resources the workload requires. A leaked role credential is far less dangerous when the role can barely do anything.
  • Audit DescribeInstanceAttribute access. Treat the ability to read user data as a sensitive permission and limit who holds it.
  • Bake secret scanning into both code and cloud. Scan repos in CI and keep continuous scanning on the live account with Lensix so drift, manual launches, and console edits get caught.
  • Assume any exposed secret is compromised. Rotation is not optional cleanup. It is the first step, every time.

Secrets in user data are an easy mistake to make and an easy one to exploit. Once you move credential handling over to instance roles and a secrets store, this entire class of finding disappears and your bootstrap scripts get simpler in the process.