AWS IAM Security: 10 Essential Best Practices
Cloud

AWS IAM Security: 10 Essential Best Practices

December 16, 20249 min readAWSIAMSécurité

IAM misconfiguration is the number-one cause of AWS security incidents. Here are the 10 practices that every team must apply to secure their AWS environment.

Why IAM Is the #1 Attack Surface

According to Gartner, 80% of cloud security incidents involve misconfigured IAM. This figure is alarming but logical: IAM is the nervous system of your AWS infrastructure. An exposed access key, an overly permissive role, or an unprotected root account can compromise your entire AWS organisation within minutes. Real attacks we have handled during security audits almost always follow the same pattern: IAM key found in a public GitHub repository → full production access → data exfiltration or large-scale cryptomining.

This guide details the practices we apply systematically during audits and deployments. They are not theoretical — they come from real incidents and their remediations.

1. Root Account: Mandatory MFA and Break-Glass Procedure

The AWS root account is the only identity that cannot be restricted by SCPs. It is also the most dangerous. The rule is simple: the root account must never be used for daily operations, and its access must be locked behind a formal procedure.

# SCP to block root account usage across the entire organisation
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyRootAccountActions",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringLike": {
          "aws:PrincipalArn": ["arn:aws:iam::*:root"]
        }
      }
    }
  ]
}

# Root account checklist:
# [ ] Hardware MFA (YubiKey) enabled
# [ ] Root email = secure shared mailbox, not a personal address
# [ ] Root password stored in a vault (HashiCorp Vault, 1Password Teams)
# [ ] Break-glass procedure documented and tested quarterly
# [ ] CloudTrail alert on any root account activity

2. Least Privilege — Never Use Wildcards in Production

The principle is simple to state, difficult to apply: every identity must have only the permissions it actually needs. In practice, this means starting with deny-all and adding permissions incrementally. The wildcard "Action": "*" or "Resource": "*" is an absolute anti-pattern in production.

# BAD: overly permissive policy (commonly seen in audits)
{
  "Effect": "Allow",
  "Action": "s3:*",
  "Resource": "*"
}

# GOOD: specific S3 read-only policy with conditions
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadSpecificBucketWithConditions",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-production-bucket",
        "arn:aws:s3:::my-production-bucket/*"
      ],
      "Condition": {
        "Bool": {
          "aws:MultiFactorAuthPresent": "true"
        },
        "IpAddress": {
          "aws:SourceIp": ["10.0.0.0/8", "192.168.0.0/16"]
        }
      }
    }
  ]
}

3. IAM Roles Over Long-Lived Access Keys

IAM access keys (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) are the primary source of compromise. They do not auto-rotate, are often accidentally committed to source code, and circulate in logs and environment variables. The rule: remove all long-lived access keys and replace them with roles.

  • EC2: Instance Profile attached to the instance — credentials are automatically rotated every hour via IMDS
  • Lambda: Execution Role with minimal permissions — no keys in environment variables
  • ECS/Fargate: Task Role — isolated per task, not per entire service
  • GitHub Actions: OIDC federation → assume role → temporary session (1 hour max, no stored keys)
  • On-premise applications: IAM Roles Anywhere with X.509 certificates issued by your PKI
# GitHub Actions: OIDC federation with AWS (zero stored secrets)
# 1. Create the OIDC provider in AWS
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

# 2. Create the role assumable by GitHub Actions
resource "aws_iam_role" "github_actions" {
  name = "GitHubActionsRole"
  assume_role_policy = jsonencode({
    Statement = [{
      Effect    = "Allow"
      Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
      Action    = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringLike = {
          "token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:*"
        }
      }
    }]
  })
}

4. Permission Boundaries: Safe Delegation to Teams

Permission Boundaries allow you to safely delegate IAM role creation to development teams without risking privilege escalation. They define the maximum permissions a delegated administrator can grant, even if they attach an AdministratorAccess policy to a role.

resource "aws_iam_policy" "developer_boundary" {
  name        = "DeveloperPermissionBoundary"
  description = "Caps the maximum permissions developers can grant"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowedServices"
        Effect = "Allow"
        Action = ["s3:*", "lambda:*", "dynamodb:*", "sqs:*", "sns:*", "logs:*"]
        Resource = "arn:aws:*:eu-west-1:123456789:*"
      },
      {
        Sid    = "DenyPrivilegedActions"
        Effect = "Deny"
        Action = [
          "iam:*",
          "organizations:*",
          "cloudtrail:DeleteTrail",
          "guardduty:DeleteDetector"
        ]
        Resource = "*"
      }
    ]
  })
}

resource "aws_iam_role" "developer_role" {
  name                 = "DeveloperRole"
  permissions_boundary = aws_iam_policy.developer_boundary.arn
  assume_role_policy   = data.aws_iam_policy_document.assume_role.json
}

5. AWS Organizations + SCPs: Organisation-Level Guardrails

Service Control Policies (SCPs) are guardrails that apply to all accounts in an OU, including roles with AdministratorAccess. They are the last line of defence against critical misconfigurations. Importantly, SCPs override even admin roles — they cannot be bypassed from within an account.

# SCP: block all non-EU regions (data sovereignty)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyNonEURegions",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": ["eu-west-1", "eu-west-3", "eu-central-1", "eu-north-1"]
        },
        "ArnNotLike": {
          "aws:PrincipalArn": ["arn:aws:iam::*:role/AdminBreakGlassRole"]
        }
      }
    }
  ]
}

# SCP: prevent disabling security services
{
  "Sid": "DenyDisableSecurityServices",
  "Effect": "Deny",
  "Action": [
    "cloudtrail:DeleteTrail",
    "cloudtrail:StopLogging",
    "guardduty:DeleteDetector",
    "securityhub:DisableSecurityHub",
    "config:DeleteConfigurationRecorder"
  ],
  "Resource": "*"
}

6. AWS IAM Access Analyzer: Automatic Detection of Overly Permissive Policies

IAM Access Analyzer continuously analyses your policies and surfaces two types of findings: unintended public access (public S3 buckets, publicly invocable Lambdas) and overly permissive policies. It is free and should be enabled in every account and every region.

# Enable Access Analyzer via Terraform
resource "aws_accessanalyzer_analyzer" "org_analyzer" {
  analyzer_name = "organization-analyzer"
  type          = "ORGANIZATION"  # Analyses the entire organisation
}

# Generate a least-privilege policy from CloudTrail activity
aws accessanalyzer start-policy-generation   --policy-generation-details '{
    "principalArn": "arn:aws:iam::123456789:role/MyAppRole"
  }'   --cloud-trail-details '{
    "trailArn": "arn:aws:cloudtrail:eu-west-1:123456789:trail/main-trail",
    "startTime": "2025-01-01T00:00:00Z",
    "endTime": "2025-06-01T00:00:00Z"
  }'
# Returns an IAM policy containing only the actions that were actually used

7. AWS CloudTrail: Complete Audit Logging and Real-Time Alerts

CloudTrail records all AWS API calls. A multi-region trail with S3 encryption and EventBridge alerts is the minimum required for any production environment.

# EventBridge rule: alert on suspicious activity
resource "aws_cloudwatch_event_rule" "root_activity" {
  name        = "detect-root-activity"
  event_pattern = jsonencode({
    detail-type = ["AWS API Call via CloudTrail"]
    detail = {
      userIdentity = { type = ["Root"] }
    }
  })
}

resource "aws_cloudwatch_event_rule" "console_no_mfa" {
  name        = "detect-console-login-without-mfa"
  event_pattern = jsonencode({
    detail-type = ["AWS Console Sign In via CloudTrail"]
    detail = {
      additionalEventData = { MFAUsed = ["No"] }
    }
  })
}

resource "aws_cloudwatch_event_target" "security_alert" {
  rule = aws_cloudwatch_event_rule.root_activity.name
  arn  = aws_sns_topic.security_alerts.arn
}

8. AWS IAM Identity Center (SSO): Federated Human Access

For human console access, IAM Identity Center (formerly AWS SSO) is the standard. It allows you to federate your corporate directory (Active Directory, Okta, Google Workspace) with AWS, centralise permissions, and enforce short session durations.

  • SAML 2.0 federation: users log in with their corporate credentials — no separate AWS passwords
  • Short session durations: configure 1–4 hours maximum for production access (not the 8-hour default)
  • Permission sets: define reusable access profiles (ReadOnly, Developer, Admin) and assign them per account and group
  • Centralised audit: all logins are logged in CloudTrail with the federated user identity

9. Terraform Example: Lambda Role with Least-Privilege Policy

resource "aws_iam_role" "lambda_processor" {
  name = "lambda-order-processor-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "lambda_processor_policy" {
  name = "order-processor-policy"
  role = aws_iam_role.lambda_processor.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "ReadFromOrdersQueue"
        Effect = "Allow"
        Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
        Resource = aws_sqs_queue.orders.arn
      },
      {
        Sid    = "WriteToOrdersTable"
        Effect = "Allow"
        Action = ["dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:GetItem"]
        Resource = aws_dynamodb_table.orders.arn
      },
      {
        Sid    = "CloudWatchLogs"
        Effect = "Allow"
        Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
        Resource = "arn:aws:logs:eu-west-1:*:log-group:/aws/lambda/order-processor:*"
      }
    ]
  })
}

10. AWS Security Hub: Continuous Compliance Scoring

Security Hub aggregates findings from GuardDuty, Inspector, IAM Access Analyzer, and Config to produce a compliance score against the CIS AWS Foundations Benchmark and AWS Foundational Security Best Practices. Target a CIS score above 90%.

resource "aws_securityhub_account" "main" {}

resource "aws_securityhub_standards_subscription" "cis" {
  depends_on    = [aws_securityhub_account.main]
  standards_arn = "arn:aws:securityhub:::ruleset/cis-aws-foundations-benchmark/v/1.4.0"
}

resource "aws_securityhub_standards_subscription" "aws_best_practices" {
  depends_on    = [aws_securityhub_account.main]
  standards_arn = "arn:aws:securityhub:eu-west-1::standards/aws-foundational-security-best-practices/v/1.0.0"
}

# Check your score
aws securityhub get-insights   --insight-arns "arn:aws:securityhub:::insight/aws/securityhub/0"

Conclusion

IAM security is not a project you complete once — it is a daily hygiene practice. Maximum-priority actions: enable MFA on all human accounts, remove all long-lived access keys in favour of roles, deploy SCPs to block unauthorised regions, and enable IAM Access Analyzer (free) to detect excessive access. These four actions alone eliminate the vast majority of documented IAM attack vectors. The rest — Permission Boundaries, Security Hub, CloudTrail alerting — constitutes the defence-in-depth that makes the difference between a contained incident and a total compromise of your AWS organisation.

← Back to blog