Skip to content

EC2 Instance Profiles and Trust Policies

An instance profile and a trust policy are frequently discussed as alternatives. They are not: an instance profile is the delivery mechanism for credentials on EC2, and a trust policy is part of every role, including the one inside the instance profile.

  • A container for exactly one IAM role, attached to an EC2 instance
  • The instance obtains temporary credentials for that role from the instance metadata service, and AWS rotates them automatically before they expire
  • Every AWS SDK and the AWS CLI pick those credentials up from the metadata service without configuration
  • Attach, replace or detach the profile on a running instance without stopping it
  • The correct mechanism for any EC2 instance that needs to call an AWS service

The role inside the instance profile has a trust policy naming the EC2 service principal. That is what allows EC2 to assume it on the instance’s behalf:

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

A trust policy is the resource-based policy on an IAM role that says who may assume it. Every role has exactly one. Valid principals are AWS accounts and their root, IAM roles and users, role sessions, federated principals (SAML or OIDC) and AWS service principals.

An EC2 instance ARN is not a valid principal. A trust policy naming arn:aws:ec2:...:instance/i-... is rejected, and there is no way to write a role that trusts one particular instance directly. Constraining a role to a specific instance is done through a condition — for example on aws:SourceVpc, aws:SourceIp or a tag — or by giving that instance its own profile and role.

Trust policies are used for:

  • Cross-account access — a role in one account trusting a principal in another
  • Service-to-service access — Lambda, ECS tasks, CodeBuild and the rest assuming a role
  • Federation — trusting a SAML or OIDC identity provider
  • Letting an AWS service such as CloudFormation act on your behalf

Worked example: an EC2 instance reading DynamoDB

Section titled “Worked example: an EC2 instance reading DynamoDB”
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:Query"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/MyTable"
}
]
}
Terminal window
aws iam create-role --role-name EC2DynamoDBRole --assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}'
Terminal window
aws iam create-instance-profile --instance-profile-name EC2DynamoDBProfile
aws iam add-role-to-instance-profile \
--instance-profile-name EC2DynamoDBProfile \
--role-name EC2DynamoDBRole
aws ec2 associate-iam-instance-profile \
--instance-id i-1234567890abcdef0 \
--iam-instance-profile Name=EC2DynamoDBProfile

Nothing about credentials appears in the code. The SDK resolves them from the instance metadata service and refreshes them as they rotate.

import boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('MyTable')
response = table.get_item(Key={'id': '123'})

Worked example: reaching a resource in another account

Section titled “Worked example: reaching a resource in another account”

This is where an explicit role assumption is genuinely required — not as an inferior alternative to an instance profile, but because the resource is somewhere the instance profile’s role does not reach.

The instance runs in account 123456789012; the DynamoDB table lives in account 444455556666. The instance keeps its instance profile, and the role in the table’s account trusts the instance profile’s role:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/EC2DynamoDBRole"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "example-external-id"
}
}
}
]
}

The application chains from the instance profile’s credentials into the second role:

import boto3
sts_client = boto3.client('sts')
assumed_role = sts_client.assume_role(
RoleArn='arn:aws:iam::444455556666:role/CrossAccountDynamoDBRole',
RoleSessionName='order-service',
ExternalId='example-external-id'
)
credentials = assumed_role['Credentials']
dynamodb = boto3.resource(
'dynamodb',
aws_access_key_id=credentials['AccessKeyId'],
aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token=credentials['SessionToken']
)
table = dynamodb.Table('MyTable')
response = table.get_item(Key={'id': '123'})

Note that the instance still has no stored credential. It starts from the instance profile and chains outwards, which is why the chained session is capped at one hour.

  • Same account: use an instance profile. There is no better option and no reason to manage credentials by hand.
  • Another account, or a service acting on your behalf: the target role’s trust policy names the principal that may assume it, and the caller chains into it with sts:AssumeRole.
  • Either way, no long-lived access key is stored anywhere.