This site deploys itself. Push to main, GitHub Actions builds the Jekyll site, assumes an IAM role over OIDC, syncs the output to S3, and invalidates CloudFront. I set that role up by hand in 2024, it has worked every single time since, and that turned out to be the problem. Nothing that works ever gets looked at.

When I finally brought the site’s infrastructure under Terraform, the deploy role was the first thing I adopted. Reading it with fresh eyes was uncomfortable.

What the role actually had

Two AWS managed policies: AmazonS3FullAccess and CloudFrontFullAccess. That’s what you attach when you’re trying to get a pipeline green on a Sunday afternoon, and it does get the pipeline green.

Here’s what else it did. The role lives in my AWS Organizations management account, which is also where a lot of my personal infrastructure lives. So “full access to S3” didn’t mean full access to the website bucket. It meant every bucket in the account, including the ones holding Terraform state for everything else I run. State is not a harmless file. It routinely contains secrets, connection strings, and the full shape of your environment in plain JSON.

“Full access to CloudFront” was worse than it sounds, too. That policy covers CloudFront KeyValueStores, and one of my other sites keeps its session signing secret in one. A workflow that only ever needed to upload HTML could have read the key that signs logins for a completely different site.

None of that was ever exercised, and that isn’t the point. The question to ask of any credential is not “what does it do?” but “what could someone do with it?” The answer here was: a lot, for anyone who could get code to run in that workflow on main.

(The deeper fix is moving workloads out of the management account entirely. That’s on the list. It’s a bigger project than one role, and the role was fixable today.)

Start from what the job does

The deploy step is two commands:

aws s3 sync ./_site s3://cloud405 --delete
aws cloudfront create-invalidation --distribution-id EXAMPLE123 --paths "/*"

s3 sync --delete lists the bucket, uploads changed objects, and deletes objects that no longer exist locally. The invalidation is one call on one distribution. That’s the entire permission surface:

data "aws_iam_policy_document" "github_deploy" {
  statement {
    sid       = "ListSiteBucket"
    actions   = ["s3:ListBucket", "s3:GetBucketLocation"]
    resources = [local.site_bucket_arn]
  }
  statement {
    sid       = "WriteSiteObjects"
    actions   = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
    resources = ["${local.site_bucket_arn}/*"]
  }
  statement {
    sid       = "InvalidateSite"
    actions   = ["cloudfront:CreateInvalidation"]
    resources = [aws_cloudfront_distribution.cloud405.arn]
  }
}

Note the split between the bucket ARN and the /* object ARN. Bucket-level actions like ListBucket apply to the bucket itself, and object actions apply to keys inside it. Getting those backwards is the most common reason a least-privilege S3 policy gets thrown out for s3:* five minutes later.

If you don’t know exactly what a job calls, you don’t have to guess. IAM Access Analyzer can generate a policy from a role’s CloudTrail history, and the role’s last-accessed data shows which services it has actually touched. Both are good starting points, not finished policies.

The trust policy is the other half

Permissions decide what a role can do. The trust policy decides who gets to become the role, and with OIDC federation that’s where most of the risk sits.

GitHub’s OIDC token carries a sub claim that looks like repo:OWNER/REPO:ref:refs/heads/main. Matching on it is where most tutorials stop, including the one I wrote in 2024, which matched repo:OWNER/REPO:*. That wildcard trusts every branch, every tag, and every environment in the repo. Anyone who can push a branch can deploy.

There’s a subtler problem underneath. sub is built from names, and names on GitHub can be renamed, deleted, and claimed again by someone else. A trust policy keyed on a name is trusting a string, not a repository.

The token also carries numeric IDs for the repository, the owner, and the account that triggered the run. Those don’t change when names do. Until recently IAM couldn’t evaluate them; trust policies were limited to a handful of standard claims, which is why everyone packed logic into sub. In February 2026, AWS STS started validating GitHub-specific claims, and AWS’s own guidance now recommends the immutable IDs over names for exactly the reclaiming reason above. So the role now pins all of them:

condition {
  test     = "StringEquals"
  variable = "token.actions.githubusercontent.com:repository_id"
  values   = [local.github_repo_id]
}
condition {
  test     = "StringEquals"
  variable = "token.actions.githubusercontent.com:repository_owner_id"
  values   = [local.github_owner_id]
}
condition {
  test     = "StringEquals"
  variable = "token.actions.githubusercontent.com:actor_id"
  values   = [local.github_owner_id]
}

Alongside those, the policy requires the audience to be sts.amazonaws.com and the ref to be exactly refs/heads/main, so a workflow on a feature branch or a pull request can’t assume the role at all. The actor_id condition means only a run I triggered can deploy. On a solo blog that’s free to add. On a team you’d scope deploys with protected environments and required reviewers instead. Sessions are capped at one hour, which is about fifty-five minutes more than a deploy takes.

Each ID is one API call away (gh api repos/OWNER/REPO --jq .id and gh api users/OWNER --jq .id), so there’s no good reason to skip it.

Making sure it stays fixed

Least privilege has a half-life. Someone (usually me, usually in a hurry) attaches a managed policy “just for now,” and now becomes forever. Two Terraform resources guard against that:

resource "aws_iam_role_policy_attachments_exclusive" "github_deploy" {
  role_name   = aws_iam_role.github_deploy.name
  policy_arns = []
}

resource "aws_iam_role_policies_exclusive" "github_deploy" {
  role_name    = aws_iam_role.github_deploy.name
  policy_names = [aws_iam_role_policy.github_deploy.name]
}

The first one, with an empty list, is what detached the two full-access policies on the first apply. After that, any managed policy attached by hand shows up as drift in the next terraform plan. The second does the same for inline policies. Configuration drift on an IAM role is a security finding, and this makes Terraform report it like one.

I adopted the existing role with an import block instead of creating a new one. The workflow references the role by ARN, so keeping the same role meant the production change was a policy change and nothing else. That’s a much easier thing to review, and a much easier thing to roll back.

A short audit for your own pipelines

If you have CI deploying to a cloud account, this takes an evening:

  1. List every role your pipelines can assume. Searching your workflows for role-to-assume is a fast way to find them.
  2. For each one, write down the commands the job actually runs and compare them to the attached policies. Anything ending in FullAccess deserves a hard look.
  3. Check which account the role lives in and what else lives there. The same permission means very different things in a sandbox and in a management account.
  4. Read the trust policy. If it matches sub with a wildcard, pin the ref. Then add the numeric repository and owner IDs.
  5. Make the role’s policy set exclusive in code, so the next shortcut shows up as drift instead of quietly becoming permanent.

The role this site deploys with can now do three things to one bucket and one distribution, and only when I push to main. For two years it could read every state file in the account. That cost nothing, but only because nobody went looking.