AWS Basics #2: IAM — Users, Groups, Roles, Policies
At the end of #1 AWS basics we said “don’t work as the root user.” So who do we work as? The answer is the user / role you create with IAM (Identity and Access Management).
IAM is a global service — open the console in any region and you see the same users / roles / policies. It’s also free. You can create unlimited users at no extra charge. So it’s the very first service you meet on a new AWS account.
In this post we’ll thread IAM’s four elements onto a single line and lay out the core patterns of production permission design.
Big picture — the four elements of IAM #
| Element | What it is | Who / what uses it |
|---|---|---|
| User | A permanent credential bound to a single human or machine | Console login, access keys |
| Group | A bundle of users | Attach a policy to the group; every user in it inherits |
| Role | A “borrow temporarily” credential | EC2, Lambda, users in another account, etc. |
| Policy | A JSON document of “what to allow / deny” | Attached to the three above to define permissions |
The key idea: the policy is the permission. Users, groups, and roles are containers for permissions, and the policies attached to them actually decide what they can do.
Policy — writing permissions in JSON #
Start with the policy. A policy is a JSON document. Getting comfortable with this shape is 80% of learning IAM.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*"
}
]
}Read it: allow the s3:GetObject action on every object (*) in my-bucket.
What each key means:
| Key | What it is |
|---|---|
Version | Policy syntax version. Always 2012-10-17 |
Statement | An array of permission rules |
Effect | Allow or Deny |
Action | Which API call to allow / deny — <service>:<action> |
Resource | Which resource — written as an ARN |
Condition (optional) | Extra conditions — IP, time, tags, etc. |
The shape of Action #
Like s3:GetObject, the form is <service>:<action>. Wildcards work too.
"Action": "s3:Get*" // every Get-* action on s3
"Action": "s3:*" // every s3 action
"Action": ["s3:GetObject", "s3:PutObject"] // multiple as an arrayThe shape of Resource #
Written as an ARN. We saw the ARN shape in #1.
"Resource": "arn:aws:s3:::my-bucket" // the bucket itself
"Resource": "arn:aws:s3:::my-bucket/*" // every object inside
"Resource": "arn:aws:s3:::my-bucket/uploads/*" // only a prefix
"Resource": "*" // all resources (dangerous!)Condition — the most powerful control #
Conditions are where a policy really earns its keep.
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": ["203.0.113.0/24"]
}
}
}{
"Effect": "Allow",
"Action": "iam:*",
"Resource": "*",
"Condition": {
"Bool": { "aws:MultiFactorAuthPresent": "true" }
}
}Conditions you’ll use often:
| Key | What it is |
|---|---|
aws:SourceIp | Request IP |
aws:MultiFactorAuthPresent | Whether MFA was used |
aws:RequestTag/<key> | Tag at creation |
aws:ResourceTag/<key> | Tag on the resource |
aws:CurrentTime | Time |
Allow / Deny evaluation order #
When multiple policies overlap, IAM decides in this order.
1) If any explicit Deny matches → denied (end of story)
2) If any explicit Allow matches → allowed
3) Neither → denied (default)Deny always wins. That’s why guardrails like “this user must never touch prod resources” go in as Deny. The pattern is to attach a Deny next to an Allow.
User — a permanent credential for humans / machines #
An IAM user is a separate ID, not an email. The console login URL is also separate.
https://<account-id>.signin.aws.amazon.com/console
or
https://<account-alias>.signin.aws.amazon.com/consoleCreating a user #
In the console, IAM → Users → Add users.
| Field | What it is |
|---|---|
| Username | Not an email. An identifier like curtis, dev-bot |
| Console access | Password — for humans |
| Programmatic access | Access keys — for CLI / SDK (#4) |
| Permissions | Add to a group, attach policies directly, or copy from another user |
Best practice: don’t attach policies directly to a user. Attach them through a group. The reason in the next section.
Access keys — the riskiest credential a user holds #
An access key is a two-line string.
Access Key ID: AKIAIOSFODNN7EXAMPLE
Secret Access Key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEYAnyone who has both can wield every permission of that user. So:
- At most two access keys per user (briefly two during rotation)
- Never put them in git or code (see #1 pitfall #2)
- Rotate every 90 days (covered in detail in #6)
- Where possible use a role instead of user access keys
Group — the unit of user management #
A group is a bundle of users. Attach a policy to the group and every user in it inherits it automatically.
Admins → AdministratorAccess
Developers → PowerUserAccess + (deny IAM)
ReadOnly → ReadOnlyAccess
Billing → billing console onlyWhy through a group instead of directly? #
Imagine a new developer joining.
Direct-on-user model:
- Pick an existing developer and copy their list of policies one by one
- “Wait, what was that policy I added to that person last week?” → missed
- Over time each developer’s permissions drift subtly
Group model:
- Just add to the
Developersgroup - Permission changes happen on the group’s policies and apply consistently
- Audits are clean by group
Rule: no inline policies / direct attachment on IAM users. All permissions through groups or roles.
Role — the “borrow temporarily” credential #
A role is not someone’s credential — it’s a credential anyone (or anything) can borrow temporarily. There’s no permanent password / access key. Instead, when someone or something calls AssumeRole, AWS issues a temporary credential (valid 1–12 hours).
This is the most important part of IAM. It’s hard at first, but once you get it the real power of IAM shows up.
Where roles fit #
| Scenario | Who calls AssumeRole |
|---|---|
| EC2 reaches S3 | The EC2 instance (automatically) |
| Lambda writes to DynamoDB | Lambda (automatically) |
| ECS Task calls another service | The task (automatically) |
| A user in another AWS account accesses some resource here | That user explicitly |
| GitHub Actions pushes to ECR | GitHub Actions Workflow via OIDC |
| Temporary privilege escalation (break-glass) | The user explicitly |
For EC2 / Lambda / ECS, AWS automatically calls AssumeRole and feeds the credential into the instance. The code just calls the SDK — it never has to deal with access keys. That’s the heart of it.
A role’s two policies #
A role has two kinds of policy attached.
1) Trust Policy — who can borrow this role.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}The Principal slot says who — a service, another account, a user, an OIDC provider, etc.
2) Permission Policy — what they can do once they’ve borrowed it.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*"
}]
}EC2 instance profile flow #
[EC2 instance]
│
│ 1) Request credentials from instance metadata (169.254.169.254)
▼
[IAM]
│ 2) Issue temporary credential for the role attached to the instance
▼
[SDK inside EC2]
│ 3) Call S3 with the credential
▼
[S3]The code inside EC2 doesn’t know any access key. boto3 / aws-sdk pulls the credential from metadata on its own. Key-leak incidents are blocked at the source.
Managed vs inline policies #
Policies also split by how they’re created.
| Kind | What it is | Recommendation |
|---|---|---|
| AWS managed | Built by AWS (e.g., AdministratorAccess, ReadOnlyAccess) | For getting started / standard cases |
| Customer managed | Created by you. Reusable across multiple users / roles | Production standard — recommended |
| Inline | Embedded directly into one user / group / role | Not recommended — not reusable, hard to track |
AWS managed policies you’ll use often #
| Name | What it is |
|---|---|
AdministratorAccess | Every permission. The most dangerous after root |
PowerUserAccess | Almost every permission except IAM |
ReadOnlyAccess | Read across every service |
Billing | The billing console |
<Service>FullAccess (e.g., AmazonS3FullAccess) | All of one service |
<Service>ReadOnlyAccess | Read of one service |
The usual flow is: start managed → tighten over time to customer-managed.
Production permission design — patterns that hold up #
The skeleton of a permission design that works even on a small team.
1) Four groups + three roles #
| Unit | Name | What it is |
|---|---|---|
| Group | Admins | AdministratorAccess (MFA enforced) |
| Group | Developers | PowerUserAccess + deny IAM |
| Group | ReadOnly | ReadOnlyAccess |
| Group | Billing | Billing / cost |
| Role | EC2-AppRole | What the app uses (S3, RDS, etc.) |
| Role | Lambda-WorkerRole | For Lambda |
| Role | CICDRole | Borrowed by GitHub Actions / CodeBuild |
2) Permission boundaries #
A guard that says “whatever this IAM user does, it’s bounded by this.” Blocks the incident where a junior developer creates a new policy to grant themselves more access.
Developers group → PowerUserAccess
↓ and a permission boundary on every user
Permission boundary → "DevOnly" — can only create dev/* resources3) Enforce MFA #
Root + every IAM user. The shape of the enforcement policy is in #6.
4) Separate user from role #
Users = humans only. Machines all use roles. Once that’s clear, access-key incidents almost stop.
5) Guardrails via conditions #
{
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringEquals": { "aws:ResourceTag/env": "prod" }
}
}If you tag consistently, environment separation comes from a single line of policy.
Policy Simulator — the verification tool #
The IAM Policy Simulator (policysim.aws.amazon.com) simulates “can this action go through?” for a given user / group / role. Use it to confirm that your policy changes do what you intended.
1) Pick a user or role
2) Pick the action to simulate (e.g., s3:DeleteObject)
3) Optionally enter a resource ARN
4) "Run Simulation" → Allowed / Denied with the policies that influenced the resultThe IAM Access Analyzer in #6 helps with similar audits.
Creating an IAM user — hands-on #
By this point, go ahead and create one IAM user for yourself. From #4 CLI onward you’ll need this user / role.
1. Console → IAM → Users → Add users
2. Username: your name (e.g., curtis)
3. Console access: enabled, auto-generated password
4. Attach policies directly: AdministratorAccess (for learning — narrow it later)
5. Right after creation → enable MFA immediately (enforcement in #6)
6. Access keys: issue one for the CLI
7. Download the .csv — the secret only appears on that screenRight after this, move on to #3 Cost management and #6 Security basics.
Common pitfalls #
1) Policies attached directly to users #
Up to ten people it looks fine, but at thirty, permissions start drifting in subtle ways. After a breach you can’t say who can do what. Round them up into groups.
2) Resource: "*" everywhere
#
Convenient at first, but blast radius on a breach is unbounded. Tighten with ARNs wherever you can.
3) Inline policies #
Convenient to add on the spot, but a month later no one knows who added what. Not reusable either. Use customer-managed policies — they get a name and versioning.
4) Access keys baked into EC2 #
Code that solves EC2 → S3 access using access keys. Move it to an instance profile (role). Forget rotation once and the incident is permanently latent.
5) “Test users” left lying around #
Former employees’ keys, bot users someone created and forgot — they all accumulate. Set up automatic deactivation or deletion for users idle for 30 or 90 days. The IAM Credential Report shows the whole picture at once.
6) Access keys on the root user #
#1 pitfall #2 — the most common incident. Root is console + MFA only, no access keys, ever.
Wrap-up #
What we covered:
- The four elements of IAM — User (permanent credential for human / machine), Group (bundle of users), Role (borrow temporarily), Policy (JSON permission document)
- Shape of a policy —
Effect+Action+Resource(+Condition). Deny beats Allow - No direct policy attachment on users → bundle into groups
- A role’s two policies — Trust Policy (who can borrow) + Permission Policy (what they can do once they have it)
- EC2 / Lambda / ECS get credentials via instance profile / execution role — keep keys out of code
- Managed vs inline — customer-managed is the production standard
- Permission design patterns — four groups + three roles, permission boundaries, MFA enforcement, user / role separation, tag + condition guardrails
- Pitfalls — direct user policies,
Resource:*, inline policies, access keys inside EC2, idle users, root keys
Next — cost management #
Now that you have an IAM user, next up is billing. The biggest incident in the first few days is idle resources growing the bill.
#3 Cost management — Billing alerts, Cost Explorer, Free Tier covers the billing alerts you must turn on right after signup, monitoring the Free Tier ceiling, and the Cost Explorer / tag strategy that scales into production.