AWS IAM Roles Explained: Best Practices for Secure Service Permissions
Learn AWS IAM Roles: how they differ from users, how AssumeRole works, and how to use them for EC2, Lambda, cross-account, and federation.
In Part 1 of this series, we covered IAM users, groups, and policies. But there’s a problem with IAM users: they have long-term credentials (access keys) that can be stolen, leaked, or compromised.
IAM Roles solve this problem by providing temporary credentials that automatically rotate. They’re the recommended way to grant permissions to AWS services, applications, and users from other accounts.
In this article, we’ll explore what IAM Roles are, how they differ from users, how the AssumeRole mechanism works, and how to use roles for EC2 instances, Lambda functions, cross-account access, and federated users.
What Are IAM Roles?
An IAM Role is an identity with permission policies, but unlike users, roles:
- Have no long-term credentials (no password or access keys)
- Can be assumed by anyone/anything that needs them
- Provide temporary security credentials that automatically expire
- Can be assumed by AWS services, users, or applications
Think of a role like a badge you temporarily wear:
- A user has a permanent employee ID (IAM user with credentials)
- A role is a visitor badge you wear while in a restricted area (temporary credentials)
- When you leave, you return the badge (credentials expire)
Roles vs Users: Key Differences
| IAM User | IAM Role |
|---|---|
| Permanent credentials | Temporary credentials |
| Access keys + password | No credentials stored |
| Specific to one person/app | Can be assumed by many |
| Credentials can be stolen | Credentials auto-rotate |
| Costs if unused | No cost |
How AssumeRole Works
The AssumeRole API is the mechanism for obtaining temporary credentials:
1. Entity (user/service) requests to assume role
2. AWS checks trust policy: "Is this entity allowed to assume this role?"
3. If yes, AWS returns temporary credentials:
- AccessKeyId
- SecretAccessKey
- SessionToken
- Expiration timestamp (15 min to 12 hours)
4. Entity uses temporary credentials for AWS API calls
5. Credentials automatically expire
Trust Policies vs Permission Policies
Roles have two types of policies:
Trust Policy (Who Can Assume)
Defines who can assume the role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
This trust policy allows EC2 instances to assume the role.
Permission Policy (What They Can Do)
Defines what actions the role can perform (same as user policies):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::my-bucket/*"
}
]
}
Both policies must allow for the role to work.
EC2 Instance Roles (Instance Profiles)
The most common use case: giving EC2 instances permissions without embedding credentials.
Without Instance Roles (Bad)
# application.py running on EC2
import boto3
# Hardcoded credentials (terrible!)
s3 = boto3.client('s3',
aws_access_key_id='AKIAIOSFODNN7EXAMPLE',
aws_secret_access_key='wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'
)
s3.upload_file('file.txt', 'my-bucket', 'file.txt')
Problems:
- Credentials in code/config files
- Can be stolen from instance
- Must be rotated manually
- If leaked, attacker has access until you rotate
With Instance Roles (Good)
Create role:
# 1. Create trust policy for EC2
cat > trust-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
EOF
# 2. Create role
aws iam create-role \
--role-name EC2S3AccessRole \
--assume-role-policy-document file://trust-policy.json
# 3. Attach permission policy
aws iam attach-role-policy \
--role-name EC2S3AccessRole \
--policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
# 4. Create instance profile (EC2 requires this wrapper)
aws iam create-instance-profile \
--instance-profile-name EC2S3AccessProfile
# 5. Add role to instance profile
aws iam add-role-to-instance-profile \
--instance-profile-name EC2S3AccessProfile \
--role-name EC2S3AccessRole
Launch EC2 with role:
aws ec2 run-instances \
--image-id ami-0c55b159cbfafe1f0 \
--instance-type t2.micro \
--iam-instance-profile Name=EC2S3AccessProfile \
--key-name my-key
Application code:
# application.py - No credentials needed!
import boto3
# AWS SDK automatically uses instance role credentials
s3 = boto3.client('s3')
s3.upload_file('file.txt', 'my-bucket', 'file.txt')
How it works:
- EC2 instance has role attached
- AWS SDK checks for instance metadata service
- Metadata service provides temporary credentials
- Credentials auto-rotate before expiring
- No credentials stored on instance
Attaching Role to Running Instance
aws ec2 associate-iam-instance-profile \
--instance-id i-1234567890abcdef0 \
--iam-instance-profile Name=EC2S3AccessProfile
Lambda Execution Roles
Lambda functions need roles to:
- Access other AWS services
- Write to CloudWatch Logs
Creating Lambda Execution Role
# Trust policy for Lambda
cat > lambda-trust-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
EOF
# Create role
aws iam create-role \
--role-name LambdaS3ReadRole \
--assume-role-policy-document file://lambda-trust-policy.json
# Attach AWS managed policy for CloudWatch Logs
aws iam attach-role-policy \
--role-name LambdaS3ReadRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
# Attach custom policy for S3 access
cat > s3-read-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
]
}
]
}
EOF
aws iam put-role-policy \
--role-name LambdaS3ReadRole \
--policy-name S3ReadPolicy \
--policy-document file://s3-read-policy.json
Create Lambda function with role:
aws lambda create-function \
--function-name MyFunction \
--runtime python3.11 \
--role arn:aws:iam::123456789012:role/LambdaS3ReadRole \
--handler lambda_function.lambda_handler \
--zip-file fileb://function.zip
Lambda function code:
import boto3
import json
s3 = boto3.client('s3')
def lambda_handler(event, context):
# Automatically uses execution role credentials
response = s3.get_object(Bucket='my-bucket', Key='data.json')
data = json.loads(response['Body'].read())
return {
'statusCode': 200,
'body': json.dumps(data)
}
Cross-Account Access
Roles enable secure access between AWS accounts without sharing credentials.
Scenario: Account A Needs to Access Account B’s S3 Bucket
In Account B (resource account - 999999999999):
# Create role that Account A can assume
cat > trust-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111111111111:root"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "unique-external-id-12345"
}
}
}
]
}
EOF
aws iam create-role \
--role-name CrossAccountS3Access \
--assume-role-policy-document file://trust-policy.json
# Grant S3 permissions
aws iam attach-role-policy \
--role-name CrossAccountS3Access \
--policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
In Account A (assuming account - 111111111111):
# Assume role in Account B
aws sts assume-role \
--role-arn arn:aws:iam::999999999999:role/CrossAccountS3Access \
--role-session-name CrossAccountSession \
--external-id unique-external-id-12345
Response:
{
"Credentials": {
"AccessKeyId": "ASIATEMP...",
"SecretAccessKey": "wJalr...",
"SessionToken": "FwoGZX...",
"Expiration": "2025-09-02T14:00:00Z"
}
}
Use temporary credentials:
export AWS_ACCESS_KEY_ID=ASIATEMP...
export AWS_SECRET_ACCESS_KEY=wJalr...
export AWS_SESSION_TOKEN=FwoGZX...
# Now you have access to Account B's S3
aws s3 ls s3://account-b-bucket/
Or in Python:
import boto3
sts = boto3.client('sts')
assumed_role = sts.assume_role(
RoleArn='arn:aws:iam::999999999999:role/CrossAccountS3Access',
RoleSessionName='CrossAccountSession',
ExternalId='unique-external-id-12345'
)
credentials = assumed_role['Credentials']
s3 = boto3.client('s3',
aws_access_key_id=credentials['AccessKeyId'],
aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token=credentials['SessionToken']
)
# Access Account B's S3
objects = s3.list_objects_v2(Bucket='account-b-bucket')
External ID for Security
The ExternalId condition prevents the “confused deputy” problem:
- Account B trusts Account A
- Attacker tricks Account A into assuming role on their behalf
- ExternalId ensures only authorized requests succeed
Always use ExternalId for cross-account access.
Federated Access (SAML and OIDC)
Allow users from external identity providers to access AWS without creating IAM users.
SAML 2.0 Federation (Active Directory, Okta, etc.)
# Create SAML identity provider
aws iam create-saml-provider \
--saml-metadata-document file://metadata.xml \
--name MyCompanySSO
# Create role for federated users
cat > saml-trust-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:saml-provider/MyCompanySSO"
},
"Action": "sts:AssumeRoleWithSAML",
"Condition": {
"StringEquals": {
"SAML:aud": "https://signin.aws.amazon.com/saml"
}
}
}
]
}
EOF
aws iam create-role \
--role-name FederatedDeveloperAccess \
--assume-role-policy-document file://saml-trust-policy.json
# Attach permissions
aws iam attach-role-policy \
--role-name FederatedDeveloperAccess \
--policy-arn arn:aws:iam::aws:policy/PowerUserAccess
Users sign in via corporate SSO, get mapped to AWS role based on group membership.
OIDC Federation (GitHub Actions, Google, Facebook)
Example: GitHub Actions accessing AWS:
# Create OIDC provider for GitHub
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
# Create role for GitHub Actions
cat > github-trust-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:*"
}
}
}
]
}
EOF
aws iam create-role \
--role-name GitHubActionsRole \
--assume-role-policy-document file://github-trust-policy.json
aws iam attach-role-policy \
--role-name GitHubActionsRole \
--policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
In GitHub Actions workflow:
name: Deploy to S3
on: [push]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: us-east-1
- name: Deploy to S3
run: |
aws s3 sync ./dist s3://my-website-bucket/
No AWS credentials stored in GitHub—role is assumed via OIDC.
Service-Linked Roles
AWS services automatically create roles for themselves:
# List service-linked roles
aws iam list-roles --query 'Roles[?starts_with(RoleName, `AWSServiceRoleFor`)]'
Examples:
- AWSServiceRoleForElasticLoadBalancing: ELB creates/manages resources
- AWSServiceRoleForRDS: RDS manages database backups
- AWSServiceRoleForAutoScaling: Auto Scaling manages instances
You can’t modify these roles, but you can delete them if the service isn’t used:
aws iam delete-service-linked-role \
--role-name AWSServiceRoleForElasticLoadBalancing
Role Chaining
A role can assume another role (up to 12 hours total):
# Assume first role
sts = boto3.client('sts')
role1 = sts.assume_role(
RoleArn='arn:aws:iam::111111111111:role/RoleA',
RoleSessionName='Session1'
)
# Use credentials from first role to assume second role
sts2 = boto3.client('sts',
aws_access_key_id=role1['Credentials']['AccessKeyId'],
aws_secret_access_key=role1['Credentials']['SecretAccessKey'],
aws_session_token=role1['Credentials']['SessionToken']
)
role2 = sts2.assume_role(
RoleArn='arn:aws:iam::222222222222:role/RoleB',
RoleSessionName='Session2'
)
Use cases:
- Cross-account chains (Account A → B → C)
- Privilege escalation for specific tasks
Session Policies
Further restrict temporary credentials:
import json
session_policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/public/*"
}
]
}
assumed_role = sts.assume_role(
RoleArn='arn:aws:iam::123456789012:role/S3ReadRole',
RoleSessionName='RestrictedSession',
Policy=json.dumps(session_policy), # Further restricts permissions
DurationSeconds=3600
)
Even if the role has s3:* permissions, this session can only read from public/*.
Best Practices for IAM Roles
1. Prefer Roles Over Access Keys
Bad:
# Access keys embedded in application
s3 = boto3.client('s3',
aws_access_key_id='AKIAIOSFODNN7EXAMPLE',
aws_secret_access_key='wJalr...'
)
Good:
# Use instance/execution role
s3 = boto3.client('s3') # Automatically uses role
2. Use External ID for Cross-Account Access
Always include ExternalId in trust policies for third-party access.
3. Set Maximum Session Duration Appropriately
aws iam update-role \
--role-name MyRole \
--max-session-duration 3600 # 1 hour (default is 1 hour, max is 12)
Shorter duration = more secure, but may interrupt long operations.
4. Monitor Role Usage
# See when role was last used
aws iam get-role --role-name MyRole
Check RoleLastUsed field to identify unused roles.
5. Use Condition Keys for Tighter Security
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111111111111:root"
},
"Action": "sts:AssumeRole",
"Condition": {
"IpAddress": {
"aws:SourceIp": "203.0.113.0/24"
},
"DateGreaterThan": {
"aws:CurrentTime": "2025-09-01T00:00:00Z"
},
"DateLessThan": {
"aws:CurrentTime": "2025-12-31T23:59:59Z"
}
}
}
]
}
This allows assuming role only from specific IP range and time window.
Troubleshooting Role Issues
Error: “User is not authorized to perform: sts:AssumeRole”
Check:
- Trust policy allows the principal
- User/service has permission to assume role
- ExternalId matches (if required)
Error: “AccessDenied” After Assuming Role
Check:
- Role’s permission policy grants the action
- Session policy (if used) allows the action
- Resource policy (e.g., S3 bucket policy) allows the action
Debugging AssumeRole
# Simulate assuming role
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/MyRole \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::my-bucket/file.txt
Conclusion
IAM Roles are the cornerstone of secure AWS architecture. They eliminate long-term credentials, provide automatic rotation, and enable least-privilege access across services and accounts.
Key takeaways:
- Use roles instead of access keys wherever possible
- EC2 instances and Lambda functions should always use roles
- Cross-account access requires trust policies and ExternalId
- Federation (SAML/OIDC) enables SSO without creating IAM users
- Monitor role usage and apply least privilege
- Understand the difference between trust policies and permission policies
In the next part of this series, we’ll explore advanced IAM topics: permission boundaries, service control policies (SCPs), and AWS Organizations.
Build secure, scalable AWS infrastructure by mastering IAM Roles.