IAM: Users, Roles, and Policies Done Right
1. Overview
IAM (Identity and Access Management) is AWS’s foundational service that determines “who can do what with what”. Many teams get it wrong from the start: creating users with admin permissions for everything, attaching *:* policies to rush things, or confusing users and roles. This guide helps you clearly distinguish between the three concepts of user, role, and policy, write correct JSON policy syntax, apply the least privilege principle, and test role assumption using the AWS Java SDK.
After reading this, you’ll be confident designing a clean, secure IAM setup without giving “god-mode permissions” to every service.
2. Three Core Components
IAM revolves around three concepts:
- User: an identity for a specific person or application, with long-lived credentials (password, access key).
- Role: a temporary identity with no fixed credentials, which is “assumed” by a user, service, or another account.
- Policy: a JSON document describing permissions (allow/deny) attached to a user, role, group, or resource.
Note: A role is not “a user without a password”. Roles generate temporary credentials (time-limited, via STS), while users use fixed credentials until you rotate them.
3. User: When to Use, When to Avoid
Users are suitable for:
- Humans who need to log in to the Console for manual work.
- Edge cases where you can’t use roles (rare, e.g., legacy systems outside AWS needing fixed access keys).
Users are NOT suitable for:
- Workloads running on EC2, Lambda, ECS — these should use roles.
- Sharing a single user among multiple people (you lose audit capability via CloudTrail).
Create a user via CLI:
aws iam create-user --user-name order-service-deployer
aws iam create-login-profile \
--user-name order-service-deployer \
--password 'TempPass123!' \
--password-reset-required
The first line creates the user. The create-login-profile line grants Console login with a temporary password that must be changed on first login—preventing long-lived default passwords.
Common mistake: creating an access key for a user and hard-coding it in code, then committing to git. If you must use access keys, enable periodic rotation and use Secrets Manager to store them.
4. Role: Temporary Identity in the Right Place
4.1. EC2 instance role
When an EC2 instance needs to call S3, don’t embed access keys in .env. Create a role and attach an instance profile:
aws iam create-role \
--role-name OrderServiceEc2Role \
--assume-role-policy-document file://trust-policy.json
The trust-policy.json file defines who can assume this role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
Principal.Service specifies that the EC2 service can assume this role through the instance profile. This is called a trust policy—different from the permission policy described later.
4.2. Cross-account role
Roles also let you grant AWS accounts access to your account without sharing credentials:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "order-partner-2024" }
}
}
]
}
The Condition with sts:ExternalId protects against the confused deputy attack—the third party must know the secret ExternalId to assume the role, preventing a fourth party from exploiting a public ARN.
5. Policy: Structure and How to Write
A policy JSON consists of Version and Statement—each statement has Effect, Action, Resource, and optionally Condition.
Example: allow PaymentService to read/write a specific S3 bucket but not delete:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowReadWritePaymentBucket",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::payment-receipts-bucket/*"
},
{
"Sid": "DenyDeleteObject",
"Effect": "Deny",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::payment-receipts-bucket/*"
}
]
}
The first statement Allows two actions GetObject and PutObject only on payment-receipts-bucket/*. The second statement explicitly Denys deletion—even if someone later attaches a policy with s3:*, the explicit deny still wins.
Attach this policy to a role:
aws iam put-role-policy \
--role-name PaymentServiceRole \
--policy-name PaymentBucketAccess \
--policy-document file://payment-bucket-policy.json
5.1. Identity-based vs Resource-based Policy
- Identity-based policy: attached to user/role/group, answers “what can this identity do”.
- Resource-based policy: attached directly to a resource (S3 bucket policy, SQS queue policy, KMS key policy), answers “who can access this resource”.
Example resource-based bucket policy allowing only a specific role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/PaymentServiceRole"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::payment-receipts-bucket/*"
}
]
}
These two policy types can combine—the final permission is the intersection of both (both identity-based and resource-based must allow; if either denies, it’s blocked, except cross-account which requires both allow).
6. The Least Privilege Principle
Instead of writing "Action": "s3:*" for convenience, list exactly the actions needed. Use Condition to narrow the scope further.
Example: allow reading objects only from a specific VPC:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::order-exports-bucket/*",
"Condition": {
"StringEquals": {
"aws:SourceVpc": "vpc-0abc1234"
}
}
}
]
}
aws:SourceVpc is a global condition key—it only allows if the request originates from the specified VPC’s endpoint—useful when you don’t want data leaving your internal network.
Practical approach to achieving least privilege:
- Start with an empty policy (default deny).
- Run your workload and watch IAM Access Analyzer or CloudTrail logs for denied actions.
- Add exactly those actions to your policy, no more.
- Repeat until your workload runs smoothly without being blocked.
Note: Use
aws iam generate-service-last-accessed-detailsto see which actions a user/role actually used in the past 90 days—trim excess actions from your current policy accordingly.
7. Policy Evaluation Logic to Remember
AWS evaluates permissions in this order; remember it to debug quickly:
- Everything is deny by default (implicit deny).
- If any statement has an explicit
Deny—the result is deny, nothing saves you. - If no explicit deny, look for a matching
Allowstatement—allow. - If no matching allow—still deny (implicit default).
Request arrives → Is there a matching explicit Deny?
├─ Yes → DENY (end)
└─ No → Is there a matching Allow?
├─ Yes → ALLOW
└─ No → DENY (implicit)
Understanding this order helps you avoid the classic bug: attaching AdministratorAccess to a user but they’re still blocked on one action because an Organization-level SCP (Service Control Policy) explicitly denies it.
8. Test Assume Role with Java SDK
Add SDK v2 dependency:
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sts</artifactId>
<version>2.28.11</version>
</dependency>
Service calls STS to assume a role and get temporary credentials:
public class RoleAssumer {
private final StsClient stsClient;
public RoleAssumer(StsClient stsClient) {
this.stsClient = stsClient;
}
public Credentials assumePaymentServiceRole() {
AssumeRoleRequest request = AssumeRoleRequest.builder()
.roleArn("arn:aws:iam::123456789012:role/PaymentServiceRole")
.roleSessionName("payment-service-session")
.durationSeconds(900)
.build();
AssumeRoleResponse response = stsClient.assumeRole(request);
return response.credentials();
}
}
roleArn specifies which role to assume, roleSessionName names the session for easy tracing in CloudTrail, durationSeconds limits how long the temporary credentials live—shorter is safer.
Test with JUnit 5 and Mockito to verify correct role ARN is called without hitting AWS:
@Test
void assumePaymentServiceRole_shouldRequestCorrectRoleArn() {
StsClient stsClient = mock(StsClient.class);
Credentials fakeCredentials = Credentials.builder()
.accessKeyId("AKIAFAKE")
.secretAccessKey("secretFake")
.sessionToken("tokenFake")
.build();
when(stsClient.assumeRole(any(AssumeRoleRequest.class)))
.thenReturn(AssumeRoleResponse.builder()
.credentials(fakeCredentials)
.build());
RoleAssumer assumer = new RoleAssumer(stsClient);
Credentials result = assumer.assumePaymentServiceRole();
ArgumentCaptor<AssumeRoleRequest> captor = ArgumentCaptor.forClass(AssumeRoleRequest.class);
verify(stsClient).assumeRole(captor.capture());
assertThat(captor.getValue().roleArn())
.isEqualTo("arn:aws:iam::123456789012:role/PaymentServiceRole");
assertThat(result.accessKeyId()).isEqualTo("AKIAFAKE");
}
This test mocks StsClient, captures the actual request sent, and asserts the roleArn is correct. This helps catch early if someone accidentally changes a hard-coded role ARN.
assumePaymentServiceRole_shouldRequestCorrectRoleArn() PASSED
9. Common Mistakes to Avoid
- Attaching
AdministratorAccessto every service account because “we’ll optimize later”. In reality, no one ever comes back to optimize, and the risk persists forever. - Using access keys for workloads on EC2/Lambda instead of roles—access keys leak easily, don’t auto-rotate, have no expiration.
- Forgetting to review a role’s trust policy:
Principaltoo broad (e.g.,"AWS": "*") lets anyone assume it. - Not using
Conditionwith MFA for sensitive actions (deleting buckets, modifying IAM policies). Add"Bool": {"aws:MultiFactorAuthPresent": "true"}to high-risk actions. - Cramming many responsibilities into one large policy making review hard. Split by resource/service, name clearly (
PaymentBucketReadWrite, notMyPolicy1).
When you DON’T need complex IAM: If you’re doing personal sandbox work or learning a new service, creating detailed role/policy setups from the start may slow learning. Use a temporary user with a managed policy like
AmazonS3ReadOnlyAccess—just remember to delete it when done.
10. Conclusion
Users are for humans logging in manually, roles are for workloads and temporary access, policies are JSON rules that determine allow/deny. Master the evaluation order (explicit deny always wins, implicit deny is default) to debug fast when permissions act “weird”. Apply least privilege by starting empty, adding gradually based on real logs, and always write tests for your role assumption logic in code—that’s how you keep your AWS system secure without spending effort maintaining it later.