WAF++ WAF++
Back to WAF++ Homepage

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 AdministratorAccess for all developers

  • Long-lived access keys in CI/CD pipelines and .env files

  • A single role for application, deployment, and administration

  • Service accounts without MFA that allow interactive access

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
# Specific permissions
resource "aws_iam_role_policy" "app" {
  policy = jsonencode({
    Statement = [{
      Effect = "Allow"
      Action = [
        "s3:GetObject",
        "s3:PutObject"
      ]
      Resource = [
        "${aws_s3_bucket.uploads.arn}/*"
      ]
    }]
  })
}
# Wildcard: PROHIBITED
resource "aws_iam_role_policy" "app" {
  policy = jsonencode({
    Statement = [{
      Effect   = "Allow"
      Action   = "*"      # Prohibited!
      Resource = "*"      # Prohibited!
    }]
  })
}

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-user for the entire team is not auditable. Individual roles via SSO.

  • AdministratorAccess for 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 .env files: 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