Skip to main content
SecurityAugust 25, 20267 min read

How to Give Applications AWS Credentials Without Storing Any

Every long-lived access key in your account is a copy waiting to leak, and no amount of rotation discipline fixes that. The alternative is to have no key at all, because each place an application normally needs credentials already has a mechanism that hands it fresh ones on demand. This walks through instance profiles on EC2, task roles on ECS, EKS Pod Identity and IRSA on Kubernetes, and OIDC federation for a CI pipeline, with the trust policy shape for each. It also covers the one condition in the CI trust policy that decides whether the whole thing is secure or theatre.

An access key that never expires is a standing liability with no owner. It ends up in a CI secret store, a Kubernetes secret, a developer's shell profile, an old Terraform state file and a support ticket from 2023, and none of those copies has a review date.

Better key hygiene does not solve this. Having no key does. Every place an application normally needs AWS credentials has a mechanism that mints short-lived ones on demand, and in each case the thing you configure is a trust policy rather than a secret.

Start by finding out what you actually have

The credential report is the fastest inventory of long-lived keys in an account.

aws iam generate-credential-report
aws iam get-credential-report --query Content --output text | base64 --decode > credential-report.csv

Open the CSV and read the access_key_1_active, access_key_1_last_used_date and access_key_2_* columns. A key that is active and has never been used is the easiest deletion you will make all week. AWS regenerates the report at most once every four hours, so the second run inside that window returns the cached copy.

One limitation worth knowing before you treat the report as complete. It covers only the first two access keys per user and does not include service-specific credentials, so pair it with aws iam list-access-keys per user if you want full coverage.

On EC2, an instance profile

An instance profile is the container that carries an IAM role onto an instance. The role trusts ec2.amazonaws.com, and anything using an AWS SDK on the box picks the credentials up from the instance metadata service without being told about them.

# trust policy for the role
cat > ec2-trust.json <<'JSON'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "ec2.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}
JSON

aws iam create-role --role-name app-server --assume-role-policy-document file://ec2-trust.json
aws iam create-instance-profile --instance-profile-name app-server
aws iam add-role-to-instance-profile --instance-profile-name app-server --role-name app-server

# attach to a running or stopped instance
aws ec2 associate-iam-instance-profile \
  --instance-id i-1234567890abcdef0 \
  --iam-instance-profile Name=app-server

An instance profile holds exactly one role, and that limit cannot be raised. To change what an instance can do, replace the whole instance profile rather than swapping the role inside it, because AWS documents a delay of up to an hour before removing a role from a profile takes effect everywhere.

Then close the door that makes instance credentials stealable through a request-forgery bug in your own application.

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

Check the MetadataNoToken CloudWatch metric for the instance before you run that. It counts IMDSv1 calls, and when it has been flat at zero for a while, nothing on the box will break.

On ECS, the task role rather than the execution role

Two different roles appear in a task definition and people mix them up constantly. The execution role is what ECS itself uses to pull the image and write logs. The task role is what your code gets, and it is the one that replaces an access key.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": ["ecs-tasks.amazonaws.com"] },
      "Action": "sts:AssumeRole",
      "Condition": {
        "ArnLike": { "aws:SourceArn": "arn:aws:ecs:us-west-2:111122223333:*" },
        "StringEquals": { "aws:SourceAccount": "111122223333" }
      }
    }
  ]
}

The two conditions are AWS's own recommendation for scoping the role against the confused deputy problem. Specifying a particular cluster in aws:SourceArn is not currently supported, so the wildcard stays.

Once taskRoleArn is set, ECS injects AWS_CONTAINER_CREDENTIALS_RELATIVE_URI and the SDK finds it through the container credential provider. On Fargate this is the only option, since EC2 instance profiles are not available to containers there.

One caveat that matters if your tasks run on EC2 container instances rather than Fargate. Containers are not a security boundary, and tasks on the same container instance can potentially reach each other's credentials as well as the container instance role through IMDS. Fargate gives each task its own isolation boundary, which is the reason to prefer it for anything with strict isolation requirements.

On EKS, prefer Pod Identity unless you cannot use it

EKS has two mechanisms and they work differently.

EKS Pod Identity associates a role with a service account through an EKS API object. There is no OIDC provider, nothing is annotated inside the cluster, and the same role can be reused across clusters. The trust policy is short.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowEksAuthToAssumeRoleForPodIdentity",
      "Effect": "Allow",
      "Principal": { "Service": "pods.eks.amazonaws.com" },
      "Action": ["sts:AssumeRole", "sts:TagSession"]
    }
  ]
}

sts:TagSession is there because Pod Identity attaches session tags naming the cluster, namespace and service account, which you can then use in condition keys. Create the association with one command.

aws eks create-pod-identity-association \
  --cluster-name my-cluster \
  --namespace default \
  --service-account my-service-account \
  --role-arn arn:aws:iam::111122223333:role/my-role

Two prerequisites decide whether this works. The EKS Pod Identity Agent add-on must be running (it is built in on EKS Auto Mode clusters), and the node role needs eks-auth:AssumeRoleForPodIdentity, which the AmazonEKSWorkerNodePolicy managed policy already grants. If your nodes sit in private subnets, they also need a PrivateLink interface endpoint for the EKS Auth API, otherwise the agent cannot reach it.

IAM roles for service accounts is the older mechanism and still the right answer when an add-on or controller has not caught up. It needs an IAM OIDC provider created once per cluster, and the trust policy carries two conditions.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::111122223333:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:sub": "system:serviceaccount:default:my-service-account",
          "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:aud": "sts.amazonaws.com"
        }
      }
    }
  ]
}

Both conditions earn their place. Without the :sub condition any service account in the cluster can assume the role, and the :aud condition pins the audience the token was minted for.

Migrating is safe in one direction. Credentials found earlier in the SDK provider chain keep winning, so create the association or the annotation first, confirm the workload still works, and only then delete the secret it used to read.

In CI, federate instead of storing a key

A GitHub Actions workflow can exchange its own signed token for AWS credentials, so no key ever exists to be stolen from the repository settings.

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com

The thumbprint list is optional now. AWS verifies the provider's JWKS endpoint against its own library of trusted certificate authorities and only falls back to configured thumbprints when it cannot retrieve that certificate.

The role's trust policy is where the security actually lives.

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:ref:refs/heads/octo-branch"
    }
  }
}

Read that sub value carefully, because it is the whole boundary. Written as an exact string it means one repository on one branch. Loosened to a wildcard such as repo:octo-org/* with StringLike, it means every repository in the organisation, including one a contractor opens a pull request against. GitHub also supports an environment-scoped form, repo:octo-org/octo-repo:environment:prod, which pairs well with a required reviewer on that environment.

The workflow then needs permission to request the token at all.

# .github/workflows/deploy.yml
permissions:
  id-token: write
  contents: read

Without id-token: write the job never gets a token and the credential step fails with a message that looks like an IAM problem.

What to check when the migration is done

Run the credential report again and delete the keys nothing uses any more. Then stop the problem coming back by denying iam:CreateAccessKey in a service control policy, with an exception for the small number of identities that genuinely still need one. A control that only removes the existing keys leaves the account exactly one hurried afternoon away from having them again.

If you would rather have this audited and implemented on a live account than described, it is the opening move in our security and compliance work, and it is standard practice in our AWS cloud management retainers.

Talk to the engineer who will own your stack.

No account managers, no offshore handoff. Senior DevOps, direct. Tell us what you are dealing with and you get a straight answer.