Most teams we've worked with—from ride-hailing platforms to SaaS companies processing transactions at scale—still manage long-lived AWS access keys in environment files. It's a habit that dies hard, but it's also a security liability that your ops team probably warned you about. In this guide, we'll show you why IAM roles for compute: a practical guide for developers matters, how to implement it across EC2, ECS, and Lambda, and why your SDKs will thank you for making the switch.
Why long-term keys are a problem
Long-term credentials—API keys or root access keys stored in .env files, Docker images, or config management systems—have a fatal flaw: they don't expire. If someone accidentally commits them to git, extracts them from a running container, or exfiltrates them through a compromise, the attacker has the keys to your AWS account indefinitely. You can revoke them, but only if you notice they're gone.
Short-lived credentials solved this problem. Rather than your application holding a permanent key, it assumes an IAM role and receives temporary credentials (access key, secret key, and a session token) that expire in hours or sometimes minutes. When those credentials expire, the application must refresh them by calling AWS STS again. The session token adds a third factor—it's much harder to misuse stolen credentials that also require a valid session token and are expiring shortly.
AWS gives you three main mechanisms to assign roles to compute resources: instance profiles for EC2, task roles for ECS, and execution roles for Lambda. All three follow the same principle: attach an IAM role to the resource at launch time, and the AWS agent running on or alongside the resource will automatically fetch temporary credentials and make them available to your application code.
EC2 instance profiles
An EC2 instance profile is the bridge between an EC2 instance and an IAM role. You create the role, attach policies to it, then create an instance profile, associate that profile with the role, and pass the profile name when you launch the instance. The EC2 metadata service running on every instance will then expose temporary credentials via a local HTTP endpoint.
Here's a practical example. Suppose you're running a payment processing service on EC2 that needs to fetch secrets from AWS Secrets Manager and write logs to CloudWatch.
#!/bin/bash
# Create the IAM role
aws iam create-role \
--role-name PaymentProcessorRole \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}'
# Attach a policy that grants Secrets Manager and CloudWatch access
aws iam put-role-policy \
--role-name PaymentProcessorRole \
--policy-name PaymentProcessorPolicy \
--policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:payment-api-key-*"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/payment-processor/*"
}
]
}'
# Create the instance profile
aws iam create-instance-profile \
--instance-profile-name PaymentProcessorProfile
# Add the role to the instance profile
aws iam add-role-to-instance-profile \
--instance-profile-name PaymentProcessorProfile \
--role-name PaymentProcessorRole
# Launch an EC2 instance with this profile
aws ec2 run-instances \
--image-id ami-0c55b159cbfafe1f0 \
--instance-type t3.medium \
--iam-instance-profile Name=PaymentProcessorProfile
Once the instance is running, your application code doesn't need to know anything about AWS credentials. The AWS SDK for your language (Node.js, Python, Go, .NET, Java, etc.) will automatically query the instance metadata service and use those temporary credentials.
// Node.js example with AWS SDK v3
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
const client = new SecretsManagerClient({ region: "us-east-1" });
const command = new GetSecretValueCommand({ SecretId: "payment-api-key" });
const response = await client.send(command);
// SDK automatically used credentials from instance metadata service
console.log(response.SecretString);
The metadata service runs on 169.254.169.254:80 and is only accessible from inside the instance. The SDK handles the HTTP request for you.
ECS task roles
ECS introduces an extra layer. An ECS cluster contains one or more EC2 instances (or Fargate capacity), and on those instances run containers. A task is a running copy of your Docker image. The EC2 instance has an instance profile (the role the machine itself uses), but your container needs its own role—the task role.
Why the separation? The instance might run multiple containers owned by different teams or applications. Each container should have only the permissions it needs. The instance role typically has broad permissions (to pull images from ECR, write to CloudWatch, etc.), while the task role is narrowly scoped to what the application inside the container actually does.
You define the task role in your ECS task definition:
{
"family": "payment-service",
"taskRoleArn": "arn:aws:iam::123456789012:role/EcsTaskRole",
"executionRoleArn": "arn:aws:iam::123456789012:role/EcsTaskExecutionRole",
"containerDefinitions": [
{
"name": "payment-app",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/payment-service:latest",
"memory": 512,
"essential": true,
"environment": [
{
"name": "AWS_REGION",
"value": "us-east-1"
}
]
}
]
}
Note there are two roles here. The task role (taskRoleArn) is what your application assumes—it's the permissions your code gets. The execution role (executionRoleArn) is what the ECS agent uses to pull the image, retrieve secrets for environment variables, and write logs to CloudWatch on behalf of the task.
Inside the container, the SDK will find credentials the same way it does on EC2: via environment variables that the ECS agent injects. When your container starts, ECS sets AWS_CONTAINER_CREDENTIALS_RELATIVE_URI and AWS_CONTAINER_AUTHORIZATION_TOKEN, and the SDK uses these to fetch temporary credentials from a local endpoint.
Lambda execution roles
Lambda functions are ephemeral. You upload code, define memory and timeout, and AWS manages the compute. You can't SSH into the underlying machine or configure an instance profile in the traditional sense. Instead, you attach an execution role directly to the function.
# Create the Lambda execution role
aws iam create-role \
--role-name LambdaExecutionRole \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}'
# Attach a policy
aws iam put-role-policy \
--role-name LambdaExecutionRole \
--policy-name LambdaPolicy \
--policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:GetItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Orders"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:123456789012:*"
}
]
}'
# Create the Lambda function with the role
aws lambda create-function \
--function-name ProcessOrder \
--role arn:aws:iam::123456789012:role/LambdaExecutionRole \
--runtime nodejs20.x \
--handler index.handler \
--zip-file fileb://function.zip
Inside the Lambda function, the SDK again handles credential fetching automatically:
// Lambda handler
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
const dynamodb = new DynamoDBClient({ region: "us-east-1" });
export const handler = async (event) => {
const command = new PutItemCommand({
TableName: "Orders",
Item: {
orderId: { S: event.orderId },
amount: { N: event.amount.toString() },
timestamp: { N: Date.now().toString() }
}
});
await dynamodb.send(command);
return { statusCode: 200, body: "Order recorded" };
};
Lambda automatically injects AWS_LAMBDA_FUNCTION_VERSION, AWS_LAMBDA_INITIALIZATION_TYPE, and credential-related environment variables when the function executes. The SDK detects these and constructs the credential chain without any code changes on your part.
SDKs pick up role credentials automatically
This is where the elegance of the design shows. You don't need to parse environment variables yourself or write custom credential fetching logic. Every major AWS SDK (boto3 for Python, aws-sdk-js for Node.js, aws-cdk for .NET, etc.) implements a credential provider chain. When you create a client, the SDK tries multiple strategies in order:
- Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) — useful for local development or CI/CD pipelines
- Web identity token file (for OIDC or other federated identity scenarios)
- Instance metadata service (for EC2 instance profiles or ECS task roles via IMDSv2 or the ECS relative URI)
- Container credentials from the ECS relative URI
- Default credential file (~/.aws/credentials)
In production on EC2, ECS, or Lambda, the SDK will find credentials at step 3 or 4 without you doing anything. Your code just creates a client and uses it:
// Python with boto3
import boto3
# No credentials passed — boto3 will use the role
s3 = boto3.client("s3")
response = s3.list_buckets()
print(response)
This means the same code runs locally (with your ~/.aws/credentials or AWS_ACCESS_KEY_ID env vars) and in production (with the role credentials) without modification. For CI/CD, you can assume a role using a short-lived token from your CI provider (GitHub Actions OIDC, GitLab OIDC, etc.) and skip long-lived keys entirely.
Common mistakes
- Embedding credentials in Docker images or application code. A developer commits an AWS key to git "just for testing." The key gets built into the Docker image and pushed to your registry. Even if you revoke the key later, the image is cached in multiple layers. Don't do this. Use roles from day one.
- Over-permissioning roles. It's tempting to attach AdministratorAccess or attach the same role to all tasks. If a container is compromised, the attacker has broad permissions. Grant each application only the resources it actually needs. Use ARN wildcards sparingly and only for log groups or S3 paths where wildcards make sense.
- Forgetting the assume-role-policy-document. The trust relationship matters. An EC2 instance profile role must trust the ec2.amazonaws.com service. An ECS task role must trust ecs-tasks.amazonaws.com. An execution role must also trust ecs-tasks.amazonaws.com. If you get this wrong, the resource can't assume the role and your application fails with a credentials error.
- Using the execution role instead of the task role in ECS. The execution role is for ECS housekeeping (pulling images, retrieving secrets). The task role is for your application code. Don't put your application permissions in the execution role.
- Not monitoring credential usage. With long-term keys, you could audit access logs and notice unusual activity. With roles, credential usage is ephemeral and rotated frequently, which is good for security but bad for incident response if you don't log it. Enable CloudTrail and set up alarms for unusual AWS API calls from your compute resources.
Key takeaways
- Assign IAM roles to compute resources at launch time instead of managing long-term credentials in code or config files.
- EC2 instance profiles, ECS task roles, and Lambda execution roles all provide temporary credentials via local endpoints or environment variables.
- AWS SDKs automatically discover and use role credentials through their credential provider chain—no manual fetching required.
- Separate the instance or execution role (for infrastructure tasks) from the application role (for business logic permissions) in ECS to enforce least privilege.
- Scope each role narrowly to the specific resources the application needs, using ARNs and resource-based conditions where possible.
- Enable CloudTrail and monitor API calls made by role-assumed credentials to detect unauthorized activity.
- Rotate credentials frequently and let temporary tokens expire; this reduces the window of exposure if credentials are leaked.






Comments
Loading comments…