IAM: The Key to Everything
Table of Contents
Your agent spins up three Lambda functions, an API Gateway, and an S3 bucket. Each resource gets AdministratorAccess because “it works.” Two weeks later, a leaked .env file gives an attacker full access to every service in your account. Not because the key was powerful, but because every role was.
Time: About 45 minutes.
Outcome: A complete IAM setup with developer groups, least-privilege service roles for Lambda, ECS, and CI/CD, OIDC federation for GitHub Actions, and a Terraform role. No AdministratorAccess anywhere.
What We’re Building
- Developer group with scoped permissions (no IAM access, no production writes)
- Lambda execution role with least-privilege policy
- GitHub Actions OIDC federation (no access keys)
- Terraform automation role with MFA requirement
- Tag-based permission boundaries for environment isolation
- Naming convention that enables wildcard scoping
Why IAM Can’t Go Down
Every AWS service has two layers.
Data plane handles the actual work: bytes flowing into S3, Lambda functions executing, DynamoDB returning query results. This is where your application lives. Data planes are fast, scalable, and occasionally have brief hiccups during regional issues.
Control plane manages the infrastructure: creating buckets, configuring functions, setting permissions, deleting resources. When you click “Create” in the console or run terraform apply, you’re talking to the control plane.
┌─────────────────────────────────────────────────────────────────────────────┐
│ HOW AWS SERVICES WORK │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ │
│ │ CONTROL PLANE │ │
│ │ │ │
│ You/Terraform ──────▶│ Create, Update, Delete │ │
│ "aws s3 mb ..." │ Configure, Permissions │ │
│ │ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ IAM │◀─── Every request passes │
│ │ │ (Authorization) │ through IAM first │
│ │ └─────────────────┘ │ │
│ └────────────┬────────────┘ │
│ │ │
│ │ Infrastructure exists │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ DATA PLANE │ │
│ │ │ │
│ Your App ───────────▶│ Read, Write, Execute │ │
│ User traffic │ Query, Stream, Process │ │
│ │ │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Here’s what most people don’t realize: IAM is embedded in the control plane of every AWS service. It’s not a standalone service you occasionally configure. It’s the authorization layer that every single API call passes through.
When you call s3:CreateBucket, IAM evaluates your permissions before S3 even sees the request. When your CI/CD pipeline deploys a Lambda function, IAM validates the credentials first. When one service calls another, IAM checks the role’s permissions.
Every. Single. API call.
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE IAM CHECKPOINT │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ │ │ │ │ │ │ │ │
│ │ You │─────▶│ AWS API │─────▶│ IAM │─────▶│ Service │ │
│ │ │ │ │ │ │ │ │ │
│ └─────────┘ └─────────┘ └──────┬──────┘ └─────────────┘ │
│ │ │
│ │ │
│ ┌───────┴───────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────┐ ┌────────┐ │
│ │ALLOWED │ │ DENIED │ │
│ │Continue│ │ Stop │ │
│ └────────┘ └────────┘ │
│ │
│ This happens for EVERY API call: │
│ • Console clicks │
│ • CLI commands │
│ • SDK calls from your application │
│ • Service-to-service calls (Lambda → DynamoDB) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
This is why IAM is architected to never go down. AWS has built IAM as the most resilient component in their infrastructure: globally replicated, aggressively cached, designed to survive failures that would cripple other services. If IAM fails, nothing in AWS works. Not because services break, but because nothing can prove it’s allowed to do anything.
The implication: IAM isn’t something you learn “eventually.” It’s the foundation that determines whether your code runs, your deployments succeed, and your infrastructure stays secure.
Now that you understand IAM’s critical role in AWS, let’s clarify what IAM actually does, because many people confuse two related but distinct concepts.
Authentication vs Authorization
IAM handles two distinct jobs that people often conflate.
Authentication answers: “Who are you?”
When you make an AWS API call, you send credentials (access key, session token, or signed request). AWS verifies these credentials are valid and identifies which principal is making the request.
Authorization answers: “Are you allowed to do this?”
Once AWS knows who you are, IAM evaluates policies to determine if that principal can perform the requested action on the specified resource.
┌─────────────────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION vs AUTHORIZATION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌───────────────────┐ │
│ │ │ │ │ │ │ │
│ │ Request │─────▶│ AUTHENTICATION │─────▶│ AUTHORIZATION │ │
│ │ │ │ │ │ │ │
│ └─────────────┘ └──────────────────┘ └───────────────────┘ │
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ WHO are you │ │ Are you │ │
│ │ proving it? │ │ ALLOWED? │ │
│ └──────────────┘ └──────────────┘ │
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Credentials: │ │ Policies: │ │
│ │ • Access Key │ │ • Identity │ │
│ │ • Secret Key │ │ • Resource │ │
│ │ • Session │ │ • Boundaries │ │
│ │ Token │ │ • SCPs │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ │
│ AUTHENTICATION FAILURE AUTHORIZATION FAILURE │
│ ────────────────────── ───────────────────── │
│ "The security token "User: arn:aws:iam::... │
│ is invalid" is not authorized to │
│ perform: s3:GetObject" │
│ Your credentials are Your credentials are valid, │
│ wrong or expired. but you lack permission. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
When debugging access issues, the error message tells you which failed. “Invalid token” = authentication. “Not authorized” = authorization.
Principals: Who’s Making the Request?
In IAM, a principal is an entity that can make requests to AWS. Every API call comes from a principal, and IAM needs to know who that principal is before it can authorize the request.
Types of Principals
┌─────────────────────────────────────────────────────────────────┐
│ PRINCIPAL TYPES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ IAM USER │
│ ──────── │
│ A person or application with long-term credentials │
│ Example: alice@your-company │
│ │
│ IAM ROLE │
│ ──────── │
│ Temporary identity that can be assumed │
│ Example: lambda-execution-role │
│ │
│ AWS SERVICE │
│ ─────────── │
│ AWS service acting on your behalf │
│ Example: lambda.amazonaws.com │
│ │
│ FEDERATED USER │
│ ────────────── │
│ External identity (Google, Okta, GitHub) │
│ Example: user@google.com via OIDC │
│ │
│ AWS ACCOUNT │
│ ─────────── │
│ Entire AWS account (for cross-account access) │
│ Example: 123456789012 │
│ │
│ ANONYMOUS │
│ ───────── │
│ Unauthenticated requests (public S3 buckets) │
│ Example: * │
│ │
└─────────────────────────────────────────────────────────────────┘
How Principals Appear in Policies
When you write a resource-based policy (like an S3 bucket policy), you specify principals using the Principal field:
| Principal Type | Format | Example |
|---|---|---|
| AWS Account | "AWS": "arn:aws:iam::ACCOUNT_ID:root" | "AWS": "arn:aws:iam::123456789012:root" |
| IAM User | "AWS": "arn:aws:iam::ACCOUNT_ID:user/USERNAME" | "AWS": "arn:aws:iam::123456789012:user/alice" |
| IAM Role | "AWS": "arn:aws:iam::ACCOUNT_ID:role/ROLENAME" | "AWS": "arn:aws:iam::123456789012:role/lambda-role" |
| AWS Service | "Service": "SERVICE.amazonaws.com" | "Service": "lambda.amazonaws.com" |
| Federated User | "Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/PROVIDER" | "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" |
| Everyone | "AWS": "*" | "AWS": "*" (use with caution!) |
Identity-based policies don’t have a Principal field because the principal is implicit (whoever the policy is attached to). Only resource-based policies and trust policies specify principals explicitly.
Principal vs Identity
You’ll hear both terms used interchangeably:
- Principal = The entity making the request (broader term, includes services and anonymous)
- Identity = Usually refers to IAM users, groups, and roles specifically
In this guide, we use “principal” when talking about who’s making a request, and “identity” when talking about IAM users/groups/roles specifically.
Now that you know who can make requests (principals) and how IAM decides to allow them (authentication + authorization), let’s look at the simple mental model that ties everything together.
The Mental Model
IAM answers one question for every request: Should this be allowed?
Every policy you write answers three things:
WHO → CAN DO WHAT → TO WHAT
─────────────────────────────────────────────────
Identities Actions Resources
(Users, (s3:GetObject, (arn:aws:s3:::
Groups, ec2:StartInstance, my-bucket/*,
Roles) dynamodb:Query) arn:aws:ec2:...)
A policy connects these pieces. That’s it. Everything else is details.
┌─────────────────────────────────────────────────────────────────┐
│ IAM MENTAL MODEL │
├─────────────────────────────────────────────────────────────────┤
│ │
│ IDENTITY POLICY RESOURCE │
│ ──────── ────── ──────── │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ User │─────────▶│ Allow │─────────▶│ S3 │ │
│ │ Group │ │ Deny │ │ EC2 │ │
│ │ Role │ │ │ │ Lambda │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Policies answer: Can THIS identity do THIS action │
│ on THIS resource? │
│ │
└─────────────────────────────────────────────────────────────────┘
Users, Groups, Roles: The Only Rule You Need
Here’s the rule: Humans get users. Everything else gets roles.
That’s it. Internalize this and you’ll avoid 80% of IAM mistakes.
Users
Users are permanent identities with long-term credentials. They’re for humans who need to log into the console or use the CLI.
┌─────────────────────────────────────────────────────────────────┐
│ IAM USER │
├─────────────────────────────────────────────────────────────────┤
│ • Has a username and password (console access) │
│ • Can have access keys (CLI/API access) │
│ • Credentials are long-lived (until rotated) │
│ • Belongs to your AWS account │
│ │
│ USE FOR: Humans on your team │
│ AVOID FOR: Applications, services, CI/CD │
└─────────────────────────────────────────────────────────────────┘
Create users through the IAM console or CLI:
aws iam create-user --user-name alice
Groups
Groups are collections of users. Attach policies to groups, then add users to groups. Never attach policies directly to users. It doesn’t scale.
┌─────────────────────────────────────────────────────────────────┐
│ IAM GROUP │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ "Developers" │ │
│ │ │ │
│ │ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ │
│ │ │ Alice │ │ Bob │ │ Carol │ │ Dave │ │ │
│ │ └───────┘ └───────┘ └───────┘ └───────┘ │ │
│ │ │ │
│ │ Attached: DeveloperPolicy │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ All four users inherit DeveloperPolicy permissions. │
│ Add/remove users without touching policies. │
│ │
└─────────────────────────────────────────────────────────────────┘
Roles
Roles are temporary identities. They don’t have passwords or permanent access keys. Instead, something assumes the role and gets short-lived credentials (typically 1 hour).
┌─────────────────────────────────────────────────────────────────┐
│ IAM ROLE │
├─────────────────────────────────────────────────────────────────┤
│ • No permanent credentials │
│ • Issues temporary security tokens when assumed │
│ • Has a TRUST POLICY (who can assume it) │
│ • Has a PERMISSION POLICY (what they can do once assumed) │
│ │
│ USE FOR: │
│ • AWS services (Lambda, EC2, ECS) │
│ • CI/CD pipelines (GitHub Actions, GitLab) │
│ • Cross-account access │
│ • Federated users (SSO from external IdP) │
└─────────────────────────────────────────────────────────────────┘
Why roles over users for services? No credentials to leak. When Lambda assumes a role, it gets temporary tokens that expire. There’s no access key sitting in a config file waiting to be committed to Git.
Critical: Services should NEVER use IAM user access keys. Always use roles. If you find yourself creating access keys for a Lambda function, EC2 instance, or container, stop. You’re doing it wrong.
| ❌ Under | Everyone shares one IAM user with AdministratorAccess. Access keys for everything. Policies attached directly to users. No conditions, no reviews. An agent creates a Lambda role with *:* because it is the shortest path to a passing test. |
| ✅ Right | Users for humans, roles for services. Permissions through groups. AWS managed policies plus narrow custom policies. OIDC for CI/CD. Tag-based conditions for environment scoping. Access Analyzer for quarterly review. |
| ❌ Over | Hand-crafting every policy from scratch. 50-line condition blocks on every statement. Weekly permission audits for a 5-person team. Separate IAM role per developer. Custom IdP integration before you have 10 users. |
| 🤖 Agent Trap | Agent attaches AdministratorAccess to every role because it eliminates permission errors during development. Tests pass, deployment works, and every service in your account can read every secret, delete every database, and modify every IAM policy. The agent optimized for “no errors” instead of “least privilege.” |
The most important concept in that table, roles for everything except humans, deserves deeper explanation. Let’s see how roles actually work under the hood.
How AssumeRole Actually Works
Roles don’t have passwords. They can’t log in. Instead, something assumes the role and receives temporary credentials. This is the foundation of how services, CI/CD, and cross-account access work in AWS.
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE ASSUME ROLE FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ STEP 1: Request to Assume │
│ ───────────────────────── │
│ │
│ ┌──────────────┐ sts:AssumeRole ┌───────────────────┐ │
│ │ │ ───────────────────────────────▶│ │ │
│ │ Principal │ "I want to become this role" │ STS │ │
│ │ (User/Role/ │ │ (Security Token │ │
│ │ Service) │ │ Service) │ │
│ │ │ │ │ │
│ └──────────────┘ └─────────┬─────────┘ │
│ │ │
│ │ │
│ STEP 2: STS Checks the Trust Policy │ │
│ ─────────────────────────────────── │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ ROLE TRUST POLICY │ │
│ │ │ │
│ │ "Who is allowed to assume me?" │ │
│ │ │ │
│ │ { │ │
│ │ "Effect": "Allow", │ │
│ │ "Principal": { "Service": "lambda.amazonaws.com" }, │ │
│ │ "Action": "sts:AssumeRole" │ │
│ │ } │ │
│ │ │ │
│ │ ✓ Lambda service? → Allowed │ │
│ │ ✗ Random user? → Denied │ │
│ │ ✗ EC2 service? → Denied (not in trust policy) │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ │
│ STEP 3: Temporary Credentials Returned │
│ ────────────────────────────────────── │
│ │
│ If trust policy allows, STS returns: │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ { │ │
│ │ "AccessKeyId": "ASIA...", ◀── Temporary key │ │
│ │ "SecretAccessKey": "...", ◀── Temporary secret │ │
│ │ "SessionToken": "...", ◀── Required for temp creds │ │
│ │ "Expiration": "2025-01-11T12:00:00Z" ◀── Usually 1 hour │ │
│ │ } │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ │
│ STEP 4: Principal Uses Temporary Credentials │
│ ──────────────────────────────────────────── │
│ │
│ ┌──────────────┐ temp credentials ┌────────────────────────────┐ │
│ │ │ ──────────────────────▶│ │ │
│ │ Principal │ │ Any AWS Service │ │
│ │ (now acting │ s3:GetObject │ │ │
│ │ as role) │ dynamodb:Query │ Checks role's PERMISSION │ │
│ │ │ etc. │ POLICY (not trust policy)│ │
│ └──────────────┘ └────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Two Policies, Two Questions
Every role has two policies that answer different questions:
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────┐ ┌─────────────────────────────────┐ │
│ │ TRUST POLICY │ │ PERMISSION POLICY │ │
│ │ │ │ │ │
│ │ "WHO can become this role?"│ │ "WHAT can this role do?" │ │
│ │ │ │ │ │
│ │ • Lambda service │ │ • s3:GetObject on bucket X │ │
│ │ • GitHub Actions OIDC │ │ • dynamodb:Query on table Y │ │
│ │ • Specific IAM users │ │ • logs:PutLogEvents │ │
│ │ • Another AWS account │ │ │ │
│ │ │ │ │ │
│ │ Checked ONCE when │ │ Checked on EVERY API call │ │
│ │ assuming the role │ │ made with the role │ │
│ │ │ │ │ │
│ └─────────────────────────────┘ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Common Trust Policy Patterns
Allow a specific AWS service:
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
Allow users from another AWS account:
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::999888777666:root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "shared-secret-123" }
}
}
Allow GitHub Actions (OIDC federation):
{
"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:acme/app:ref:refs/heads/main"
}
}
}
Why This Matters
AssumeRole is why you don’t need access keys everywhere:
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ WITHOUT AssumeRole (Bad) WITH AssumeRole (Good) │
│ ──────────────────────── ────────────────────── │
│ │
│ Lambda function has Lambda function assumes role │
│ hardcoded access keys → gets temp credentials │
│ → keys can leak → credentials expire in 1 hour │
│ → keys never expire → nothing to leak │
│ → manual rotation → automatic rotation │
│ │
│ CI/CD has access keys CI/CD uses OIDC federation │
│ stored in secrets → proves identity to AWS │
│ → secrets can leak → gets temp credentials │
│ → broad access window → scoped to specific repo/branch │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
When you see a well-architected AWS environment, you’ll find access keys almost nowhere. Everything uses roles.
You’ve seen how roles get assumed and receive temporary credentials. Now let’s look at what happens when those credentials are used, and how IAM decides whether to allow or deny any given request.
How IAM Evaluates Every Request
When any principal (user, role, service) makes an AWS API call, IAM runs through an evaluation flow. Understanding this flow explains why your permissions sometimes work and sometimes don’t.
┌─────────────────────────────────────────────────────────────────────────────┐
│ IAM POLICY EVALUATION FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ REQUEST COMES IN │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 1. EXPLICIT DENY CHECK │ │
│ │ Is there any policy that says │ │
│ │ Deny for this action? │ │
│ └──────────────────┬──────────────────────┘ │
│ │ │
│ ┌─────────┴─────────┐ │
│ │ │ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ ┌────────┐ ┌─────────────────────────────────┐ │
│ │ DENIED │ │ 2. EXPLICIT ALLOW CHECK │ │
│ │ STOP │ │ Is there any policy that │ │
│ └────────┘ │ says Allow for this action? │ │
│ └────────────────┬────────────────┘ │
│ │ │
│ ┌─────────┴─────────┐ │
│ │ │ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌────────┐ │
│ │ ALLOWED │ │ DENIED │ │
│ └─────────┘ │(default│ │
│ └────────┘ │
│ │
│ KEY INSIGHT: Deny ALWAYS wins. │
│ If any policy says Deny, it doesn't matter how many say Allow. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The default answer is always Deny. You must have an explicit Allow, and no Deny, for a request to succeed.
This is why you’ll sometimes see this pattern:
{
"Statement": [
{
"Sid": "AllowS3ReadWrite",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/*"
},
{
"Sid": "DenyProductionDelete",
"Effect": "Deny",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::*-prod-*/*"
}
]
}
Even if another policy grants s3:*, the explicit Deny on production buckets blocks deletion. Deny is your safety net.
Understanding the evaluation flow is critical, but you also need to know how to write the policies that flow evaluates. First, let’s understand that policies can be attached in two different ways.
Policies: Reading and Writing Them
Two Directions of Permissions
Permissions can be granted from two directions:
Identity-based policies attach to a principal (user, group, role) and define what that principal can do.
Resource-based policies attach to a resource (S3 bucket, SQS queue, KMS key) and define who can access that resource.
┌─────────────────────────────────────────────────────────────────────────────┐
│ IDENTITY-BASED vs RESOURCE-BASED POLICIES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ │
│ IDENTITY-BASED RESOURCE-BASED │
│ ────────────── ────────────── │
│ │
│ Attached to: Attached to: │
│ Users, Groups, Roles S3, SQS, SNS, KMS, Lambda, etc. │
│ │
│ Answers: Answers: │
│ "What can THIS identity do?" "Who can access THIS resource?" │
│ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ │ "I can access │ │ │
│ │ ROLE │ buckets A and B" │ S3 BUCKET │ │
│ │ │ ─────────────────────▶ │ │ │
│ └──────────────┘ └──────────────┘ │
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ POLICY: │ │ POLICY: │ │
│ │ Allow │ │ Allow │ │
│ │ s3:Get* │ │ Principal: │ │
│ │ on A and B │ │ role-arn │ │
│ └──────────────┘ │ s3:Get* │ │
│ └──────────────┘ │
│ No Principal field Principal field │
│ (implicit — whoever required — specifies │
│ this is attached to) WHO can access │
│ │
│ │
│ WHEN TO USE WHICH? │
│ ────────────────── │
│ │
│ Same-account access Identity-based policy is usually enough. │
│ Attach to role, specify resource ARN. │
│ │
│ Cross-account access Need BOTH: identity-based on the role AND │
│ resource-based on the resource. │
│ (Or: role assumption with trust policy) │
│ │
│ Public access Resource-based only. Principal: "*" │
│ (No identity to attach a policy to) │
│ │
│ AWS service access Often resource-based. The service │
│ (CloudFront, SNS) needs permission │
│ granted on the resource. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Example: Same Action, Two Approaches
Approach 1: Identity-based policy on Lambda role
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*"
}
No Principal here. This attaches to the Lambda’s role. The role itself is the principal.
Approach 2: Resource-based policy on S3 bucket
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:role/my-lambda-role" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*"
}
Principal specified. This attaches to the bucket, explicitly granting access to the Lambda role.
For same-account access, either works. Most teams use identity-based policies because they’re easier to manage: all permissions for a role are in one place.
Policy Structure
Policies are JSON documents. Every policy has the same structure:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DescriptiveName",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": "192.168.1.0/24"
}
}
}
]
}
Breaking it down:
| Field | What it does | Example |
|---|---|---|
Version | Policy language version. Always use 2012-10-17 | "2012-10-17" |
Statement | Array of permission rules | [{...}, {...}] |
Sid | Optional identifier for the statement | "AllowS3Read" |
Effect | Allow or Deny | "Allow" |
Action | What API calls this covers | "s3:GetObject" or ["s3:Get*", "s3:List*"] |
Resource | What ARNs this applies to | "arn:aws:s3:::bucket/*" |
Condition | Optional constraints | IP ranges, tags, MFA, time |
ARNs (Amazon Resource Names)
ARNs uniquely identify resources. Format:
arn:aws:service:region:account-id:resource-type/resource-id
Examples:
arn:aws:s3:::my-bucket # S3 bucket (no region — globally unique)
arn:aws:s3:::my-bucket/* # All objects in bucket
arn:aws:dynamodb:us-east-1:123456789012:table/users # DynamoDB table
arn:aws:lambda:us-east-1:123456789012:function:* # All Lambda functions
arn:aws:iam::123456789012:user/alice # IAM user (no region — IAM is global)
Managed vs Inline Policies
Managed policies exist independently and can be attached to multiple identities. AWS provides many (full list), and you can create your own.
Inline policies are embedded directly in a user, group, or role. They’re deleted when the identity is deleted.
The startup answer: Start with AWS managed policies. Create custom managed policies when you need more specific permissions. Avoid inline policies. They’re harder to audit and don’t scale.
Before attaching a policy, test it. The IAM Policy Simulator lets you check whether a policy allows or denies specific actions without actually making those calls.
Your First IAM Setup
Let’s build a real IAM structure. We’ll create:
- A Developer group for engineers who build features
- A Lambda service role for a function that reads S3 and writes DynamoDB
- A GitHub Actions role for CI/CD deployments
- A Terraform role for infrastructure automation (we’ll use this in Part 4)
Naming and Tagging Strategies
Before creating resources, decide on a naming convention. Your IAM policies will use these patterns to scope permissions.
Option 1: Environment in Name (Simple)
Resource naming: {project}-{environment}-{resource}
Examples:
acme-dev-orders-bucket
acme-staging-orders-bucket
acme-prod-orders-bucket
Policy pattern:
"Resource": "arn:aws:s3:::acme-dev-*/*" # Dev only
"Resource": "arn:aws:s3:::acme-prod-*/*" # Prod only
Option 2: Tags (Flexible)
Resource tagging:
Environment: dev | staging | prod
Team: backend | frontend | data
Project: orders | payments | inventory
Policy pattern using conditions:
"Condition": {
"StringEquals": {
"aws:ResourceTag/Environment": "dev"
}
}
Option 3: Separate Accounts (Robust)
Account structure:
111111111111 - Development
222222222222 - Staging
333333333333 - Production
Policy pattern:
Different roles per account, no cross-environment risk
The startup answer: Start with Option 1 (environment in name). It’s simple, works with wildcards, and doesn’t require tag enforcement. Move to Option 3 (separate accounts) when you have a team >10 or handle sensitive data.
For this guide, we’ll use the naming pattern: {company}-{env}-{resource}
Organizing Your Policy Files
When managing IAM policies as code, organize them in a clear structure:
- iam/
- policies/
- developer-policy.json
- lambda-order-processor-policy.json
- github-actions-deploy-policy.json
- terraform-policy.json
- trust-policies/
- lambda-trust-policy.json
- github-actions-trust-policy.json
- terraform-trust-policy.json
- README.md
- policies/
- terraform/
- iam.tf
- variables.tf
This structure keeps policies version-controlled, reviewable, and easy to find.
Template 1: Developer Group
Developers need to read most things, write to dev/staging, and stay out of production data.
┌─────────────────────────────────────────────────────────────────┐
│ DEVELOPER GROUP │
├─────────────────────────────────────────────────────────────────┤
│ │
│ WHO: Engineers who build features │
│ │
│ CAN: │
│ ✓ Read logs, metrics, configs (all environments) │
│ ✓ Describe/list resources (EC2, Lambda, ECS, RDS, etc.) │
│ ✓ Write to dev and staging (S3, DynamoDB, invoke Lambda) │
│ ✓ Deploy to dev and staging (update Lambda code, ECS) │
│ │
│ CANNOT: │
│ ✗ Write to production resources │
│ ✗ Modify IAM (no privilege escalation) │
│ ✗ Change billing, account settings, or CloudTrail │
│ ✗ Create/delete infrastructure (that's Terraform's job) │
│ │
└─────────────────────────────────────────────────────────────────┘
Create the policy (developer-policy.json):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOnlyAccessAllEnvironments",
"Effect": "Allow",
"Action": [
"ec2:Describe*",
"ecs:Describe*",
"ecs:List*",
"eks:Describe*",
"eks:List*",
"lambda:Get*",
"lambda:List*",
"rds:Describe*",
"dynamodb:Describe*",
"dynamodb:List*",
"s3:GetBucketLocation",
"s3:ListAllMyBuckets",
"elasticache:Describe*",
"sqs:Get*",
"sqs:List*",
"sns:Get*",
"sns:List*",
"cloudwatch:Describe*",
"cloudwatch:Get*",
"cloudwatch:List*",
"logs:Describe*",
"logs:Get*",
"logs:FilterLogEvents",
"logs:StartQuery",
"logs:StopQuery",
"logs:GetQueryResults",
"xray:Get*",
"xray:BatchGet*",
"tag:GetResources"
],
"Resource": "*"
},
{
"Sid": "S3ReadAll",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::acme-*",
"arn:aws:s3:::acme-*/*"
]
},
{
"Sid": "DynamoDBReadAll",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:Scan"
],
"Resource": "arn:aws:dynamodb:*:*:table/acme-*"
},
{
"Sid": "DevStagingS3Write",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::acme-dev-*/*",
"arn:aws:s3:::acme-staging-*/*"
]
},
{
"Sid": "DevStagingDynamoDBWrite",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem"
],
"Resource": [
"arn:aws:dynamodb:*:*:table/acme-dev-*",
"arn:aws:dynamodb:*:*:table/acme-staging-*"
]
},
{
"Sid": "DevStagingLambdaDeploy",
"Effect": "Allow",
"Action": [
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:InvokeFunction"
],
"Resource": [
"arn:aws:lambda:*:*:function:acme-dev-*",
"arn:aws:lambda:*:*:function:acme-staging-*"
]
},
{
"Sid": "DenyProdWrite",
"Effect": "Deny",
"Action": [
"s3:PutObject",
"s3:DeleteObject",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem",
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration"
],
"Resource": [
"arn:aws:s3:::acme-prod-*/*",
"arn:aws:dynamodb:*:*:table/acme-prod-*",
"arn:aws:lambda:*:*:function:acme-prod-*"
]
},
{
"Sid": "DenyIAMAndDangerousActions",
"Effect": "Deny",
"Action": [
"iam:*",
"organizations:*",
"account:*",
"budgets:*",
"ce:*",
"cloudtrail:DeleteTrail",
"cloudtrail:StopLogging",
"cloudtrail:UpdateTrail"
],
"Resource": "*"
}
]
}
Why this works:
- Explicit
Denyon prod prevents accidents even if another policy allows - No IAM permissions = no privilege escalation
- Wildcards on naming convention (
acme-dev-*) scope without listing every resource - Read access to everything helps debugging; write access is limited
If you’re using Terraform (covered in Part 4), here’s the equivalent setup:
Template 2: Lambda Service Role
Every Lambda function needs an execution role. This role defines what the function can access. One role per function (or function group with identical needs).
Let’s create a role for a Lambda that:
- Reads order files from S3
- Writes order status to DynamoDB
- Sends notifications to SQS
┌─────────────────────────────────────────────────────────────────┐
│ LAMBDA: ORDER PROCESSOR ROLE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SERVICE: Lambda function that processes incoming orders │
│ │
│ NEEDS: │
│ ✓ s3:GetObject on orders bucket │
│ ✓ dynamodb:PutItem, UpdateItem, GetItem on orders table │
│ ✓ sqs:SendMessage to notification queue │
│ ✓ logs:* for CloudWatch logging │
│ │
│ DOES NOT NEED: │
│ ✗ Any other S3 bucket │
│ ✗ Any other DynamoDB table │
│ ✗ Any other SQS queue │
│ ✗ Any other AWS service │
│ │
└─────────────────────────────────────────────────────────────────┘
Trust policy (lambda-trust-policy.json), which defines who can assume this role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
Permission policy (lambda-order-processor-policy.json):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOrdersFromS3",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:GetObjectVersion"
],
"Resource": "arn:aws:s3:::acme-prod-orders-incoming/*"
},
{
"Sid": "WriteToOrdersTable",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:GetItem",
"dynamodb:Query"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:ACCOUNT_ID:table/acme-prod-orders",
"arn:aws:dynamodb:us-east-1:ACCOUNT_ID:table/acme-prod-orders/index/*"
]
},
{
"Sid": "SendToNotificationQueue",
"Effect": "Allow",
"Action": [
"sqs:SendMessage",
"sqs:GetQueueUrl"
],
"Resource": "arn:aws:sqs:us-east-1:ACCOUNT_ID:acme-prod-order-notifications"
},
{
"Sid": "CloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:ACCOUNT_ID:log-group:/aws/lambda/acme-prod-order-processor:*"
}
]
}
Assign to Lambda:
aws lambda create-function \
--function-name acme-prod-order-processor \
--role arn:aws:iam::ACCOUNT_ID:role/acme-prod-order-processor-role \
--runtime nodejs20.x \
--handler index.handler \
--zip-file fileb://function.zip
Why this works:
- Scoped to exact resources, not
s3:*on* - Includes DynamoDB indexes (common oversight)
- CloudWatch logs scoped to this function’s log group
- Trust policy only allows Lambda service, so humans can’t assume this role
Template 3: GitHub Actions CI/CD Role
CI/CD pipelines should never use access keys. Use OIDC federation. GitHub proves its identity to AWS, AWS issues temporary credentials. No secrets to rotate or leak.
┌─────────────────────────────────────────────────────────────────┐
│ GITHUB ACTIONS DEPLOY ROLE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ WHO: GitHub Actions workflow (main branch only) │
│ │
│ CAN: │
│ ✓ Push images to ECR │
│ ✓ Update Lambda function code │
│ ✓ Update ECS services │
│ ✓ Invalidate CloudFront distributions │
│ │
│ CANNOT: │
│ ✗ Create or delete infrastructure │
│ ✗ Modify IAM roles or policies │
│ ✗ Access secrets directly (uses service roles) │
│ ✗ Deploy from non-main branches │
│ │
│ SECURITY: OIDC federation — no access keys stored anywhere │
│ │
└─────────────────────────────────────────────────────────────────┘
Step 1: Create OIDC Provider (one-time setup)
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
One-time setup: You only need to create the OIDC provider once per AWS account. After that, you can create multiple roles that trust it.
Step 2: Trust policy (github-actions-trust-policy.json):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::ACCOUNT_ID: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:YOUR_ORG/YOUR_REPO:ref:refs/heads/main"
}
}
}
]
}
Step 3: Permission policy (github-actions-deploy-policy.json):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ECRAuth",
"Effect": "Allow",
"Action": "ecr:GetAuthorizationToken",
"Resource": "*"
},
{
"Sid": "ECRPush",
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "arn:aws:ecr:us-east-1:ACCOUNT_ID:repository/acme-*"
},
{
"Sid": "LambdaDeploy",
"Effect": "Allow",
"Action": [
"lambda:UpdateFunctionCode",
"lambda:GetFunction",
"lambda:UpdateFunctionConfiguration"
],
"Resource": "arn:aws:lambda:us-east-1:ACCOUNT_ID:function:acme-prod-*"
},
{
"Sid": "ECSUpdate",
"Effect": "Allow",
"Action": [
"ecs:UpdateService",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:RegisterTaskDefinition",
"ecs:ListTasks",
"ecs:DescribeTasks"
],
"Resource": "*",
"Condition": {
"ArnLike": {
"ecs:cluster": "arn:aws:ecs:us-east-1:ACCOUNT_ID:cluster/acme-prod-*"
}
}
},
{
"Sid": "ECSPassRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::ACCOUNT_ID:role/acme-prod-*-task-role",
"arn:aws:iam::ACCOUNT_ID:role/acme-prod-*-execution-role"
],
"Condition": {
"StringEquals": {
"iam:PassedToService": "ecs-tasks.amazonaws.com"
}
}
},
{
"Sid": "CloudFrontInvalidation",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
"cloudfront:ListInvalidations"
],
"Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/*"
}
]
}
Create the role:
aws iam create-role \
--role-name github-actions-deploy \
--assume-role-policy-document file://github-actions-trust-policy.json
aws iam create-policy \
--policy-name GitHubActionsDeployPolicy \
--policy-document file://github-actions-deploy-policy.json
aws iam attach-role-policy \
--role-name github-actions-deploy \
--policy-arn arn:aws:iam::ACCOUNT_ID:policy/GitHubActionsDeployPolicy
Use in GitHub Actions (.github/workflows/deploy.yml):
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions-deploy
aws-region: us-east-1
- name: Deploy
run: |
# Your deployment commands here
aws lambda update-function-code \
--function-name acme-prod-api \
--zip-file fileb://function.zip
Why this works:
- OIDC = no access keys anywhere
- Branch restriction (
refs/heads/main) = only main can deploy to prod iam:PassRolewith condition = can assign task roles during deployment but can’t create new powerful roles- Scoped ECR access = can only push to your repositories
Template 4: Terraform Role
Terraform needs broad permissions to create infrastructure, but should still be scoped. We’ll create the role now and use it in Part 4.
┌─────────────────────────────────────────────────────────────────┐
│ TERRAFORM ROLE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ WHO: Terraform (run locally or in CI/CD) │
│ │
│ CAN: │
│ ✓ Create, modify, delete infrastructure resources │
│ ✓ Manage IAM roles and policies (for service roles) │
│ ✓ Read everything for state management │
│ │
│ CANNOT: │
│ ✗ Modify its own role (no self-escalation) │
│ ✗ Touch billing or organization settings │
│ ✗ Delete CloudTrail or audit logs │
│ │
│ NOTE: This is powerful. Restrict who can assume it. │
│ │
└─────────────────────────────────────────────────────────────────┘
Trust policy (terraform-trust-policy.json):
For local development, allow specific IAM users. For CI/CD, use OIDC.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowDevOpsUsers",
"Effect": "Allow",
"Principal": {
"AWS": [
"arn:aws:iam::ACCOUNT_ID:user/alice",
"arn:aws:iam::ACCOUNT_ID:user/bob"
]
},
"Action": "sts:AssumeRole",
"Condition": {
"Bool": {
"aws:MultiFactorAuthPresent": "true"
}
}
},
{
"Sid": "AllowGitHubActionsInfra",
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::ACCOUNT_ID: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:YOUR_ORG/infrastructure:ref:refs/heads/main"
}
}
}
]
}
Permission policy (terraform-policy.json):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FullAccessForInfrastructure",
"Effect": "Allow",
"Action": [
"ec2:*",
"ecs:*",
"eks:*",
"lambda:*",
"s3:*",
"dynamodb:*",
"rds:*",
"elasticache:*",
"sqs:*",
"sns:*",
"events:*",
"cloudwatch:*",
"logs:*",
"cloudfront:*",
"route53:*",
"acm:*",
"elasticloadbalancing:*",
"autoscaling:*",
"secretsmanager:*",
"ssm:*",
"kms:*",
"ecr:*"
],
"Resource": "*"
},
{
"Sid": "IAMForServiceRoles",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:DeleteRole",
"iam:GetRole",
"iam:PassRole",
"iam:UpdateRole",
"iam:TagRole",
"iam:UntagRole",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:GetRolePolicy",
"iam:CreatePolicy",
"iam:DeletePolicy",
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:ListPolicyVersions",
"iam:CreatePolicyVersion",
"iam:DeletePolicyVersion",
"iam:CreateInstanceProfile",
"iam:DeleteInstanceProfile",
"iam:GetInstanceProfile",
"iam:AddRoleToInstanceProfile",
"iam:RemoveRoleFromInstanceProfile",
"iam:ListInstanceProfilesForRole",
"iam:CreateServiceLinkedRole",
"iam:DeleteServiceLinkedRole"
],
"Resource": "*"
},
{
"Sid": "IAMReadOnly",
"Effect": "Allow",
"Action": [
"iam:List*",
"iam:Get*"
],
"Resource": "*"
},
{
"Sid": "TerraformStateBackend",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::acme-terraform-state",
"arn:aws:s3:::acme-terraform-state/*"
]
},
{
"Sid": "TerraformStateLock",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:ACCOUNT_ID:table/acme-terraform-locks"
},
{
"Sid": "DenySelfEscalation",
"Effect": "Deny",
"Action": [
"iam:*"
],
"Resource": [
"arn:aws:iam::ACCOUNT_ID:role/terraform-role",
"arn:aws:iam::ACCOUNT_ID:policy/TerraformPolicy"
]
},
{
"Sid": "DenyDangerousActions",
"Effect": "Deny",
"Action": [
"organizations:*",
"account:*",
"budgets:*",
"ce:*",
"cloudtrail:DeleteTrail",
"cloudtrail:StopLogging"
],
"Resource": "*"
}
]
}
Create the role:
aws iam create-role \
--role-name terraform-role \
--assume-role-policy-document file://terraform-trust-policy.json
aws iam create-policy \
--policy-name TerraformPolicy \
--policy-document file://terraform-policy.json
aws iam attach-role-policy \
--role-name terraform-role \
--policy-arn arn:aws:iam::ACCOUNT_ID:policy/TerraformPolicy
Use with Terraform (in Part 4, we’ll set up the full workflow):
# Assume the role before running Terraform
aws sts assume-role \
--role-arn arn:aws:iam::ACCOUNT_ID:role/terraform-role \
--role-session-name terraform-session
# Or configure in ~/.aws/config
[profile terraform]
role_arn = arn:aws:iam::ACCOUNT_ID:role/terraform-role
source_profile = default
mfa_serial = arn:aws:iam::ACCOUNT_ID:mfa/alice
Why this works:
- MFA required for human users
- OIDC for CI/CD (infrastructure repo, main branch only)
- Explicit deny on self-modification prevents escalation
- State backend permissions built-in
- Can’t touch billing, organizations, or audit logs
Creating the Terraform role with Terraform creates a chicken-and-egg problem. You’ll need to create this role once using AWS CLI (or with admin credentials), then Terraform can manage it going forward.
Least Privilege Without Paralysis
The theory is “grant minimum permissions needed.” The reality is you don’t always know what’s needed until something breaks.
Practical approach:
-
Start with AWS managed policies.
ReadOnlyAccess,PowerUserAccess, job-function policies. They’re battle-tested. -
Use Access Analyzer. After workloads run for 30+ days, IAM Access Analyzer can generate policies based on actual usage.
-
Narrow over time. Replace broad managed policies with specific custom policies as you understand usage patterns.
-
Use conditions. Even broad action permissions become safe with the right conditions:
{
"Effect": "Allow",
"Action": "ec2:*",
"Resource": "*",
"Condition": {
"StringEquals": {
"ec2:ResourceTag/Environment": "dev"
}
}
}
This allows all EC2 actions but only on resources tagged Environment: dev.
Mistakes That Will Burn You
1. Wildcards Everywhere
// 🔴 Don't do this
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
// 🔴 Or this
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}
// 🟢 Do this
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/*"
}
2. Access Keys for Services
# 🔴 Don't do this
# Lambda with hardcoded credentials
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
# 🟢 Do this
# Lambda with execution role — no credentials in code
aws lambda create-function \
--role arn:aws:iam::ACCOUNT_ID:role/my-lambda-role \
...
3. Forgetting DynamoDB Index Permissions
// 🔴 Doesn't work for GSI queries
"Resource": "arn:aws:dynamodb:*:*:table/orders"
// 🟢 Include indexes
"Resource": [
"arn:aws:dynamodb:*:*:table/orders",
"arn:aws:dynamodb:*:*:table/orders/index/*"
]
4. Missing iam
When a service creates resources that use roles (Lambda, ECS, EC2), the caller needs iam:PassRole:
// Without this, "Access Denied" when creating Lambda
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::ACCOUNT_ID:role/my-lambda-role",
"Condition": {
"StringEquals": {
"iam:PassedToService": "lambda.amazonaws.com"
}
}
}
5. Copy-Pasting Without Understanding
That Stack Overflow policy might:
- Be for a different use case
- Be outdated
- Be overly permissive
- Be missing required permissions
Always use the Policy Simulator to test before attaching.
Pro tip: When you get an “Access Denied” error, check CloudTrail. It shows exactly which action was denied and which policy caused the denial. This is infinitely faster than guessing.
6. Not Using Conditions
Conditions are underused. They let you:
- Require MFA:
"Bool": {"aws:MultiFactorAuthPresent": "true"} - Restrict by IP:
"IpAddress": {"aws:SourceIp": "203.0.113.0/24"} - Enforce encryption:
"StringEquals": {"s3:x-amz-server-side-encryption": "AES256"} - Scope by tag:
"StringEquals": {"aws:ResourceTag/Environment": "dev"} - Limit time:
"DateLessThan": {"aws:CurrentTime": "2025-12-31T23:59:59Z"}
What’s Ahead: Organizations and Identity Center
Once your team grows past 5-10 people, individual IAM users become a management headache. Two services help:
AWS Organizations
AWS Organizations lets you manage multiple AWS accounts as a unit. Benefits:
- Consolidated billing. One bill, cost allocation by account.
- Service Control Policies (SCPs). Guardrails that apply to entire accounts.
- Account factory. Spin up new accounts with consistent baselines.
Set up Organizations early. It’s free and makes everything easier later.
IAM Identity Center (SSO)
IAM Identity Center replaces IAM users for humans. Instead of managing passwords and access keys in AWS, you connect your identity provider (Okta, Azure AD, Google Workspace) and manage access centrally.
When to adopt:
- More than 3 people need AWS access
- You already have an identity provider
- You want one place to manage access
We’ll go deeper on these in a future post on multi-account architecture.
Summary
┌─────────────────────────────────────────────────────────────────┐
│ IAM CHECKLIST │
├─────────────────────────────────────────────────────────────────┤
│ │
│ □ Users for humans, roles for everything else │
│ □ Permissions assigned to groups, not users │
│ □ Every service has its own role (Lambda, ECS, etc.) │
│ □ CI/CD uses OIDC federation, not access keys │
│ □ Naming convention enables wildcard scoping │
│ □ Explicit Deny on production for non-prod identities │
│ □ No IAM permissions for developers (prevents escalation) │
│ □ MFA required for privileged access │
│ □ Terraform role ready (use in Part 4) │
│ │
└─────────────────────────────────────────────────────────────────┘
IAM STRUCTURE
SERVICE ROLES
CI/CD
INFRASTRUCTURE
BEST PRACTICES
0 of 0 completed
Key Takeaways
- Humans get users, everything else gets roles. Internalize this rule and you avoid 80% of IAM mistakes.
- Attach policies to groups, not users. When the team grows from 3 to 30, you change group membership, not 30 individual policy attachments.
- OIDC federation for CI/CD means no long-lived credentials to rotate, leak, or forget about in a
.envfile. - Start with AWS managed policies and narrow them with Access Analyzer after 90 days of real usage data, not before.
- Conditions (tags, MFA, IP restrictions) let you grant broad permissions safely, which is how you get developer velocity without security theater.
What’s Coming
Next in Part 3: Your Local AWS Setup, we configure the AWS CLI with IAM Identity Center so you never hardcode access keys again. The IAM roles you built today become the identities your local environment assumes.
Further Reading
- IAM Identities (users, groups, roles)
- IAM Policies and Permissions
- IAM Security Best Practices
- IAM Policy Simulator
- IAM Access Analyzer
- Configuring OIDC for GitHub Actions
Have questions? Found an error? Open an issue or reach out on LinkedIn.
How Credentials Work (The Short Version)
When you make an AWS API call, your credentials create a signature that proves your identity without sending your secret key over the network.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CREDENTIAL TYPES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ LONG-TERM CREDENTIALS (IAM Users) │
│ ───────────────────────────────── │
│ │
│ Access Key ID AKIAIOSFODNN7EXAMPLE Public identifier │
│ Secret Access Key wJalrXUtnFEMI/K7MDENG... Never shared, never │
│ sent over network │
│ │
│ These don't expire. If leaked, attacker has access until you rotate. │
│ │
│ │
│ TEMPORARY CREDENTIALS (Roles) │
│ ───────────────────────────── │
│ │
│ Access Key ID ASIAIOSFODNN7EXAMPLE Starts with ASIA │
│ Secret Access Key wJalrXUtnFEMI/K7MDENG... (not AKIA) │
│ Session Token FwoGZXIvYXdzEBY... Required for temp creds │
│ Expiration 2025-01-11T12:00:00Z Usually 1 hour │
│ │
│ These expire automatically. Even if leaked, damage is time-limited. │
│ │
│ │
│ WHY TEMPORARY IS BETTER │
│ ─────────────────────── │
│ │
│ Long-term keys: • Can leak in git history, logs, env vars │
│ • Must manually rotate │
│ • Unlimited blast radius if compromised │
│ │
│ Temporary creds: • Expire automatically (usually 1 hour) │
│ • No secrets to store or rotate │
│ • Limited damage window if compromised │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Rule of thumb: If you’re typing an access key into a config file, there’s probably a better way. Services should use roles. CI/CD should use OIDC. Humans should use Identity Center.
--- Related Posts
Your First 60 Minutes in AWS: What Everyone Skips and Regrets Later
What everyone skips and regrets later. Set up your AWS account the right way: secure enough to sleep at night, without the enterprise overhead that kills velocity.
Your Local AWS Setup: Stop Hardcoding Access Keys Forever
Stop hardcoding access keys forever. Configure your local development environment the right way: AWS CLI, IAM Identity Center, and credential security that scales with your team.
Terraform Fundamentals: Why Clicking Is Technical Debt
Stop clicking through the AWS console. Set up your Terraform project from scratch with remote state, proper structure, and templates for everything we've built so far.
OpenTelemetry Setup: Observability That Grows With You
Set up the OTel Collector once, choose your backend. SigNoz or Grafana stack. Your observability foundation for the entire series.
Git & GitHub: The Foundation Your Whole Team Builds On
The foundation your whole team, humans and agents, builds on. Git conventions, agent commit hygiene, and attribution that tracks who wrote what.
Branch Protection & PR Workflow: Quality Gates for Humans and Agents
Quality gates that catch both human and agent mistakes. Branch protection, PR templates with agent-review sections, and CODEOWNERS for your growing team.
Pre-Commit Hooks & Code Quality: Your Agents' First Guardrail
Your agents' first guardrail, and your Scorecard's first data source. Pre-commit hooks that catch what human review misses, especially from AI-generated code.