IAM Roles and Least-Privilege Policies for CI/CD Pipelines on AWS

devops-cicd

Every week, somewhere in the world, a CI/CD secret rotation gets skipped, a long-lived access key gets committed to a repository, or a wildcard IAM policy quietly grants far more than anyone intended. The fix is not procedural — it is architectural. Temporary credentials issued through IAM role assumption, scoped to the exact branch and repository that needs them, eliminate entire categories of credential exposure. This post covers how to build that architecture correctly.

When to Use Scoped IAM Roles Instead of Access Keys

The default pattern many teams start with — an IAM user, a static access key, and a CI secret — works until it doesn’t. Static credentials do not expire, they accumulate permissions over time, and they are difficult to audit across multiple pipelines. The moment you have more than one pipeline touching AWS, the credential management overhead compounds.

Scoped IAM roles with short-lived sessions are the right model in the following situations:

  • Your CI provider supports OIDC federation (GitHub Actions, GitLab CI, CircleCI, Buildkite). This is the preferred path — no stored secrets at all.
  • You are running builds inside AWS on CodeBuild, ECS, or EC2. Attach a service role directly; no credential exchange needed.
  • A third-party SaaS tool needs access to your AWS account. Use sts:ExternalId in the trust condition to prevent confused deputy attacks.
  • You operate a shared AWS account where multiple teams deploy. Permissions boundaries on each role prevent privilege escalation across team boundaries.

If your pipeline currently uses AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY stored as CI secrets, every section below is relevant to you.

Setup

Before writing any Terraform, verify that the trust relationship you intend to create actually works. For OIDC-based providers, the OIDC provider must be registered once per AWS account. For GitHub Actions, that registration looks like this:

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

The thumbprint is tied to the certificate chain of the OIDC provider’s TLS endpoint. GitHub’s thumbprint has been stable, but you should verify it independently when setting up a new provider — AWS documents the procedure for calculating it from the provider’s certificate.

Once the role exists (we will create it with Terraform in the next section), validate the trust policy before running any pipeline against it:

aws sts assume-role \
  --role-arn arn:aws:iam::123456789012:role/ci-deploy-github-actions \
  --role-session-name ci-session

A successful response returns a set of temporary credentials with an expiry timestamp. If the trust policy is misconfigured, you will see an explicit error here rather than a silent pipeline failure. Run this check after every trust policy change.

For shared accounts, attach a permissions boundary when creating the role to cap what any inline or managed policy can grant:

aws iam create-role \
  --role-name ci-deploy-github-actions \
  --assume-role-policy-document file://trust-policy.json \
  --permissions-boundary arn:aws:iam::123456789012:policy/ci-boundary

Configuration

The Terraform below lives at iam/roles/ci-deploy.tf and covers three concerns in one file: OIDC provider registration, the trust policy scoped to a specific repository and branch, and the least-privilege permission policy for artifact deployment.

# iam/roles/ci-deploy.tf
# Least-privilege IAM role for GitHub Actions OIDC — kuryzhev.cloud tutorial

locals {
  github_org    = "my-org"
  github_repo   = "my-app"
  aws_account   = "123456789012"
  deploy_bucket = "my-app-artifacts-prod"
}

# Register GitHub OIDC provider (once per account)
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

# Trust policy: only main branch of the specific repo may assume this role
data "aws_iam_policy_document" "ci_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${local.github_org}/${local.github_repo}:ref:refs/heads/main"]
    }
  }
}

# Least-privilege permission policy: deploy artifacts only
data "aws_iam_policy_document" "ci_permissions" {
  statement {
    sid     = "S3DeployArtifacts"
    effect  = "Allow"
    actions = ["s3:PutObject", "s3:GetObject", "s3:ListBucket"]
    resources = [
      "arn:aws:s3:::${local.deploy_bucket}",
      "arn:aws:s3:::${local.deploy_bucket}/*"
    ]
  }

  statement {
    sid     = "ECRPushImage"
    effect  = "Allow"
    actions = [
      "ecr:GetAuthorizationToken",
      "ecr:BatchCheckLayerAvailability",
      "ecr:PutImage",
      "ecr:InitiateLayerUpload",
      "ecr:UploadLayerPart",
      "ecr:CompleteLayerUpload"
    ]
    resources = ["arn:aws:ecr:us-east-1:${local.aws_account}:repository/my-app"]
  }
}

resource "aws_iam_role" "ci_deploy" {
  name                 = "ci-deploy-github-actions"
  assume_role_policy   = data.aws_iam_policy_document.ci_trust.json
  max_session_duration = 3600

  tags = {
    ManagedBy   = "terraform"
    Environment = "prod"
    Purpose     = "ci-least-privilege"
  }
}

resource "aws_iam_role_policy" "ci_deploy_inline" {
  name   = "ci-deploy-permissions"
  role   = aws_iam_role.ci_deploy.id
  policy = data.aws_iam_policy_document.ci_permissions.json
}

A few decisions worth explaining explicitly. The max_session_duration is set to 3600 seconds (one hour). CI jobs that run longer than that are a separate problem — do not extend session duration as a workaround. The ECRPushImage statement scopes ecr:GetAuthorizationToken to * because that action does not accept resource-level conditions, but every other ECR action is scoped to the specific repository ARN. This is a deliberate and documented exception, not carelessness.

Before merging any policy change, validate effective permissions using the CLI simulator:

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/ci-deploy-github-actions \
  --action-names s3:PutObject \
  --resource-arns arn:aws:s3:::my-app-artifacts-prod/build.zip

The IAM Policy Simulator UI at https://policysim.aws.amazon.com provides the same capability with a visual diff — useful when reviewing policy changes in a pull request with someone who does not live in the CLI.

Examples

The Terraform above handles GitHub Actions OIDC. Here are the equivalent configurations for GitLab CI and CodeBuild, since the trust policy structure differs meaningfully between providers.

GitLab CI — OIDC trust condition. GitLab’s OIDC subject claim uses a different format. The trust policy condition block changes to:

condition {
  test     = "StringLike"
  variable = "gitlab.com:sub"
  values   = ["project_path:my-group/my-project:ref_type:branch:ref:main"]
}

# GitLab OIDC provider registration
resource "aws_iam_openid_connect_provider" "gitlab" {
  url             = "https://gitlab.com"
  client_id_list  = ["https://gitlab.com"]
  thumbprint_list = ["<gitlab-thumbprint>"]
}

The GitLab subject claim includes the project path, ref type, and ref name. Scoping to ref_type:branch:ref:main ensures that only the main branch pipeline can assume the role — tag pipelines and merge request pipelines will be denied unless you explicitly add their claim patterns.

CodeBuild service role. When the build runs inside AWS, there is no OIDC exchange. The service role is attached directly to the CodeBuild project, and the trust policy is simpler:

data "aws_iam_policy_document" "codebuild_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["codebuild.amazonaws.com"]
    }
  }
}

# CodeBuild requires these permissions or builds produce no log output
data "aws_iam_policy_document" "codebuild_permissions" {
  statement {
    sid    = "CloudWatchLogs"
    effect = "Allow"
    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
    resources = [
      "arn:aws:logs:us-east-1:123456789012:log-group:/aws/codebuild/my-project",
      "arn:aws:logs:us-east-1:123456789012:log-group:/aws/codebuild/my-project:*"
    ]
  }
}

Third-party SaaS CI with ExternalId. When a SaaS CI provider (not GitHub, not GitLab, but a vendor-hosted system) assumes a role in your account, add an sts:ExternalId condition. Without it, any entity that discovers your role ARN could potentially assume it from another AWS account:

condition {
  test     = "StringEquals"
  variable = "sts:ExternalId"
  values   = ["your-vendor-provided-external-id"]
}

The external ID value is provided by the SaaS vendor during their onboarding flow. Treat it as a shared secret — it belongs in a secrets manager, not hardcoded in Terraform source.

Common Mistakes

These are the patterns that appear in incident reports and security reviews, not hypothetical edge cases.

Wildcard resource on S3 write actions. Setting "Resource": "*" on s3:PutObject means the CI role can write to any bucket in the account. The correct resource is the specific bucket ARN and its object prefix. This is the single most common IAM mistake in CI configurations:

# Wrong — grants write access to every bucket in the account
"Resource": "*"

# Correct — scoped to the deployment bucket only
"Resource": [
  "arn:aws:s3:::my-app-artifacts-prod",
  "arn:aws:s3:::my-app-artifacts-prod/*"
]

OIDC subject condition set to wildcard. A trust policy with "token.actions.githubusercontent.com:sub": "*" allows any GitHub Actions workflow in any repository to assume the role. This is equivalent to making the role public. Always scope the sub claim to the specific organization, repository, and branch.

Missing audience condition. The aud condition (token.actions.githubusercontent.com:aud = sts.amazonaws.com) is not optional. Without it, tokens issued for other services can be replayed against your role. Both conditions — sub and aud — must be present.

CodeBuild role missing CloudWatch Logs permissions. Builds that lack logs:CreateLogGroup and logs:PutLogEvents on the correct log group ARN will fail silently — the build status shows as failed but the log stream contains nothing. This is a frustrating failure mode because there is no error message to debug. Scope the log permissions to the specific log group ARN for the project, not to *.

Session duration extended beyond 3600 seconds. The maximum session duration on a role can be set up to 12 hours. Teams sometimes increase this when a long-running deploy job hits the one-hour limit. The correct fix is to break the job into shorter stages or use a step function for long-running orchestration — not to issue credentials that live for half a day. Temporary credentials that outlive the job that requested them are a liability.

Skipping the policy simulator before merging. IAM policy evaluation involves multiple layers — identity policies, resource policies, SCPs, and permissions boundaries. A policy that looks correct in isolation can be blocked by an SCP or overridden by a resource policy. Running aws iam simulate-principal-policy before merging a policy change takes thirty seconds and catches evaluation surprises before they become pipeline outages.

official docs

Leave a Reply

Your email address will not be published. Required fields are marked *

Support us · 💳 Monobank