AL.
🇪🇸 ES
Back to blog
AWS · 10 min read

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.

AWS IAM — Part 2 of 2


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 UserIAM Role
Permanent credentialsTemporary credentials
Access keys + passwordNo credentials stored
Specific to one person/appCan be assumed by many
Credentials can be stolenCredentials auto-rotate
Costs if unusedNo 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:

  1. EC2 instance has role attached
  2. AWS SDK checks for instance metadata service
  3. Metadata service provides temporary credentials
  4. Credentials auto-rotate before expiring
  5. 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:

  1. Trust policy allows the principal
  2. User/service has permission to assume role
  3. ExternalId matches (if required)

Error: “AccessDenied” After Assuming Role

Check:

  1. Role’s permission policy grants the action
  2. Session policy (if used) allows the action
  3. 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.