Best Practice: Identity & Access Management
Context
Identity & Access Management is the foundation of all other security controls. An attacker who takes over an identity with excessive permissions can disable all other protective measures.
Common IAM misconfigurations in practice:
-
Root account used daily for deployments
-
IAM users with
AdministratorAccessfor all developers -
Long-lived access keys in CI/CD pipelines and
.envfiles -
A single role for application, deployment, and administration
-
Service accounts without MFA that allow interactive access
Related Controls
-
WAF-SEC-010 – Identity & Access Management Baseline
-
WAF-SEC-020 – Least Privilege & RBAC Enforcement
Target State
A mature IAM setup is:
-
Role-based: No long-lived IAM user credentials in production
-
Minimal: Each role only has the permissions it concretely needs
-
MFA-protected: All users with console access have MFA
-
Time-limited: Elevated permissions are granted only for the duration of a task
-
Auditable: All privileged actions are logged in CloudTrail
IAM Role Hierarchy
A recommended role structure for cloud platforms:
Organization level (AWS Organizations)
├── OrganizationAdmin (only for org management, JIT access)
│
Account level (each AWS account)
├── PlatformAdmin
│ ├── May: manage VPCs, IAM roles, KMS, landing zone
│ └── May not: application resources, production data
│
├── Developer
│ ├── May: read and write application resources (own account)
│ └── May not: change IAM policies, KMS keys, CloudTrail
│
├── ReadOnly
│ ├── May: read all resources
│ └── May not: write operations
│
└── CI/CD Deployment (only for pipeline runs)
├── May: Terraform apply for defined resources
└── May not: change IAM policies, disable CloudTrail
Technical Implementation
Step 1: IAM Password Policy (WAF-SEC-010)
resource "aws_iam_account_password_policy" "default" {
minimum_password_length = 14
require_uppercase_characters = true
require_lowercase_characters = true
require_numbers = true
require_symbols = true
allow_users_to_change_password = true
max_password_age = 90
password_reuse_prevention = 24
hard_expiry = false
}
Step 2: Enforce MFA via IAM Policy
# Policy: All actions except MFA setup require an active MFA session
resource "aws_iam_policy" "require_mfa" {
name = "RequireMFA"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowViewAccountInfo"
Effect = "Allow"
Action = [
"iam:GetAccountPasswordPolicy",
"iam:GetAccountSummary",
"iam:ListVirtualMFADevices"
]
Resource = "*"
},
{
Sid = "AllowManageOwnMFA"
Effect = "Allow"
Action = [
"iam:CreateVirtualMFADevice",
"iam:EnableMFADevice",
"iam:GetUser",
"iam:ListMFADevices",
"iam:ResyncMFADevice"
]
Resource = "arn:aws:iam::*:user/$${aws:username}"
},
{
Sid = "DenyWithoutMFA"
Effect = "Deny"
NotAction = [
"iam:CreateVirtualMFADevice",
"iam:EnableMFADevice",
"iam:GetUser",
"iam:ListMFADevices",
"iam:ResyncMFADevice",
"sts:GetSessionToken"
]
Resource = "*"
Condition = {
BoolIfExists = {
"aws:MultiFactorAuthPresent" = "false"
}
}
}
]
})
}
Step 3: Service Account Roles with IRSA (EKS)
# OIDC provider for EKS cluster
data "tls_certificate" "eks" {
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}
resource "aws_iam_openid_connect_provider" "eks" {
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint]
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}
# IAM role for Kubernetes service account (IRSA)
resource "aws_iam_role" "app_service_account" {
name = "app-service-account-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.eks.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" =
"system:serviceaccount:production:app-service-account"
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" =
"sts.amazonaws.com"
}
}
}]
})
}
# Minimal policy: read-only S3 access to a specific bucket
resource "aws_iam_role_policy" "app_s3_read" {
name = "app-s3-read"
role = aws_iam_role.app_service_account.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:ListBucket"
]
Resource = [
aws_s3_bucket.app_data.arn,
"${aws_s3_bucket.app_data.arn}/*"
]
}]
})
}
Step 4: CI/CD OIDC Integration without Static Keys
# GitHub Actions OIDC provider
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1"
]
}
# Deployment role for GitHub Actions
resource "aws_iam_role" "github_actions_deploy" {
name = "github-actions-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Only main branch and PR merges
"token.actions.githubusercontent.com:sub" =
"repo:myorg/myrepo:ref:refs/heads/main"
}
}
}]
})
}
MFA Enforcement: Methods and Trade-offs
| MFA Method | Security Level | User Experience | Recommendation |
|---|---|---|---|
Hardware token (YubiKey) |
Very high (phishing-resistant) |
Medium |
Admin roles, privileged access |
TOTP app (Google Authenticator, Authy) |
High |
Good |
All console users |
SMS OTP |
Low (SIM swapping attacks) |
Very good |
Not recommended |
AWS SSO with FIDO2 |
Very high (phishing-resistant) |
Very good |
Target for organizations with AWS SSO |
Least Privilege: Compliant vs. Non-Compliant
| Compliant | Non-Compliant |
|---|---|
|
|
Anti-Patterns
-
Using root account daily: Root should only be used for account management (very rarely). For everything else: IAM roles.
-
Shared IAM user for teams: A shared
deploy-userfor the entire team is not auditable. Individual roles via SSO. -
AdministratorAccessfor CI/CD: CI/CD only needs permissions for the resources it manages. -
Permanent admin roles: Daily work as admin is a permanent risk. JIT access instead.
-
Access keys in
.envfiles: OIDC and instance profiles are the secure alternatives.
Metrics
-
Share of IAM users with MFA enabled (Target: 100%)
-
Number of active root access keys (Target: 0)
-
Share of deployments via OIDC instead of static keys (Target: 100% for new systems)
-
IAM Access Analyzer findings (publicly accessible resources): Target: 0
-
Time since last IAM access review (Target: < 90 days)
Maturity Levels
Level 1 – Root account used daily, no MFA requirement
Level 2 – MFA for admins, coarse role separation, no AdministratorAccess for all
Level 3 – No wildcards, IRSA for service accounts, CI/CD via OIDC, strict password policy
Level 4 – IAM Access Analyzer active, JIT for privileged roles, permission boundaries
Level 5 – Zero standing privilege, continuous access review, FIDO2 everywhere