Task role vs execution role: the ECS distinction everyone confuses
If you're running containers on ECS, you've probably felt the friction of IAM permissions at some point. Most developers treat the execution role and task role as the same thing—or worse, give both full admin access just to make errors disappear. The truth is, these are two completely separate IAM roles with different jobs, and confusing them causes real security gaps in production.
Let me walk you through what each one does, why they matter, and how to structure them properly.
What is an execution role?
The execution role is assumed by the ECS agent itself. It's the role that allows ECS to do infrastructure-level things on your behalf. Think of it as the role that gives ECS permission to manage the task before and after your application code runs.
Specifically, the execution role needs permissions to:
- Pull container images from ECR (Elastic Container Registry)
- Write logs to CloudWatch
- Read secrets from Secrets Manager or Parameter Store
- Decrypt KMS-encrypted data if you're using that
- Pull from private registries if you're using Docker Hub or other services
Here's a minimal execution role policy for a typical service:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/ecs/my-service:*"
},
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-service/db-password-*"
}
]
}
Notice the resource ARNs are specific. We're not saying "allow pulling from any ECR repo" or "write logs anywhere." We're being precise about what this role can access. That specificity is your first line of defense.
What is a task role?
The task role is assumed by your application code inside the container. It's what allows your app to call AWS APIs directly. If your Lambda needs permission to invoke SQS, or your service needs to read from DynamoDB, or your worker needs to publish to SNS—that's all governed by the task role.
The task role should contain only the permissions your application actually uses. No more, no less.
Let me show you a real example. Say I'm building an order processing service that:
- Reads messages from an SQS queue
- Updates order status in DynamoDB
- Uploads receipts to S3
Here's what the task role policy looks like:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes"
],
"Resource": "arn:aws:sqs:us-east-1:123456789012:order-queue"
},
{
"Effect": "Allow",
"Action": [
"dynamodb:UpdateItem",
"dynamodb:GetItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/orders"
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject"
],
"Resource": "arn:aws:s3:::my-receipts-bucket/orders/*"
}
]
}
Notice what's missing: we're not allowing DeleteTable, we're not allowing PutObject to any bucket, and we're not allowing any SQS management actions. We're being surgical about it. Your application only has access to what it needs to function.
Keep both minimal
The principle of least privilege isn't just security theater—it's practical damage control. When your credentials are compromised, the blast radius is limited to what that role can do.
I've seen services running with execution roles that can delete CloudWatch logs or task roles that have full S3 access to production data. That's not defensive, that's reckless. Every permission you grant is a permission an attacker could exploit if they somehow exfiltrate your task credentials (which is rare, but possible in container escape scenarios).
For the execution role, audit what your container actually needs to start. You don't need permission to decrypt every secret in your account—just the ones your service reads. You don't need permission to call DescribeLogGroups or DeleteLogStream—just CreateLogStream and PutLogEvents.
For the task role, run your app and check CloudWatch for "access denied" errors. Those tell you exactly what you're missing. Add them one by one, not in bulk.
Here's the pattern I use: start with an empty statement, deploy, watch the logs, add one action at a time. It takes an extra hour but saves months of potential incident response.
Separate roles per service
This is where most teams slip up. It's tempting to create one "standard" task role and reuse it across all services. Don't. Every service should have its own task role.
Why? Because your payment service doesn't need to read from the logging service's queue, and your analytics worker doesn't need to update the user database. If one service is compromised, a shared role gives the attacker access to all of them.
Here's how I structure it in Terraform:
resource "aws_iam_role" "order_service_task_role" {
name = "order-service-task-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
}
resource "aws_iam_role_policy" "order_service_policy" {
name = "order-service-policy"
role = aws_iam_role.order_service_task_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"sqs:ReceiveMessage",
"sqs:DeleteMessage"
]
Resource = aws_sqs_queue.order_queue.arn
},
{
Effect = "Allow"
Action = ["dynamodb:UpdateItem"]
Resource = aws_dynamodb_table.orders.arn
}
]
})
}
Notice how tight this is. The order service can only do what the order service needs. If you need to add a payment processing service later, you create a separate role with only payment-related permissions.
The same applies to the execution role. You can reuse a "standard" execution role if you want (since pulling logs and reading secrets is pretty uniform), but I'd still prefer to scope it to specific log groups and secret paths per service.
Common mistakes
Mistake 1: Giving the execution role the same permissions as the task role. Your ECS agent doesn't need to call SQS or DynamoDB. It only needs to bootstrap the environment. Keep them separated.
Mistake 2: Using a single "shared" task role across multiple services. I see this constantly, especially in teams rushing to deploy. One compromised service means all your services are compromised. The overhead of creating separate roles is minimal.
Mistake 3: Granting permissions with wildcards. "s3:*" on "Resource": "*" is never the answer. Even if it feels faster initially, you'll spend more time debugging security issues later.
Mistake 4: Forgetting that the execution role needs KMS permissions. If your secrets are encrypted with a KMS key, your execution role needs permission to decrypt. This trips people up constantly.
Mistake 5: Not testing your policies before deployment. Deploy with restrictive policies, watch for access denied errors, then add the minimum to fix them. Don't start permissive and try to restrict later—you'll miss things.
Key takeaways
- The execution role is for ECS infrastructure: pulling images, writing logs, reading secrets. The task role is for your application code to call AWS APIs.
- Both roles should follow least privilege. Grant only what's needed, scoped to specific resources, not wildcards.
- Create separate task roles for each service. One compromised service shouldn't give an attacker access to your entire platform.
- Test policies incrementally. Start restrictive, deploy, check logs, add one permission at a time.
- Use specific ARNs in every policy statement. Avoid wildcards unless there's a genuine technical reason (like ecr:GetAuthorizationToken which requires "*" as the resource).






Comments
Loading comments…