Since July 2025, every push to main has built this site and deployed it to S3 and CloudFront from GitHub Actions with no AWS key stored anywhere. Each run asks GitHub for a signed token that says which repo and branch it's running for, and AWS swaps it for credentials that last an hour.

Before that, an access key sat in GitHub's secrets. The short version: getting rid of the key was easy and worth it, but for over a year the role it assumed could do far more than deploy a website. That part is at the end.

Before: a key in GitHub's secrets

From 2023, the workflow signed in with an IAM user's access key, stored as two repository secrets:

- uses: aws-actions/configure-aws-credentials@v1
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: us-east-1

That key never expired, worked from any machine, and anything in the workflow (including every third-party action it ran) could read it. GitHub hides secrets in logs, but a step can still send one somewhere.

How OIDC works instead

GitHub runs an OpenID Connect (OIDC) provider. A job that's allowed to can ask it for a short-lived token, signed by GitHub, with claims about the run. The one that matters is sub, such as repo:my-org/my-site:ref:refs/heads/main.

sequenceDiagram
    participant J as Deploy job
    participant G as GitHub OIDC
    participant S as AWS STS
    participant A as S3 and CloudFront
    J->>G: token, please (id-token: write)
    G-->>J: signed token: repo, branch
    J->>S: AssumeRoleWithWebIdentity
    Note over S: checks GitHub's signature<br/>and the role's sub condition
    S-->>J: credentials, valid 1 hour
    J->>A: upload, invalidate

AWS trusts the token because the account has GitHub registered as an identity provider, and the role's trust policy says which sub values may assume it.

The AWS side, in Terraform

The provider:

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

The thumbprint is a leftover: AWS now checks GitHub's certificate against its own list of trusted authorities, and newer versions of the Terraform provider let you leave it out.

The role trusts tokens from that provider, but only for this repo's main branch:

resource "aws_iam_role" "github_actions" {
  name = "GitHubActionsRole"
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Effect    = "Allow",
      Principal = { Federated = aws_iam_openid_connect_provider.github.arn },
      Action    = "sts:AssumeRoleWithWebIdentity",
      Condition = {
        StringLike = {
          "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.repo_name}:ref:refs/heads/main"
        }
      }
    }]
  })
}

The sub condition is the whole security model. Without it, any GitHub repo in the world could assume the role. A pull request's token says repo:<owner>/<repo>:pull_request instead, so even a workflow changed in a pull request can't deploy.

Its permissions are the deploy and nothing else: put, delete and list in the site's bucket, and invalidating one CloudFront distribution:

Action   = ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
Resource = [var.frontend_bucket_arn, "${var.frontend_bucket_arn}/*"]
# and, in a second policy:
Action   = "cloudfront:CreateInvalidation",
Resource = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.cloudfront_distribution_id}"

The workflow side

Two things: permission to request the token, and the role to assume.

deploy:
  needs: build
  if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
  runs-on: ubuntu-latest
  permissions:
    contents: read
    id-token: write
  steps:
    - uses: aws-actions/configure-aws-credentials@v6
      with:
        role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
        aws-region: ${{ secrets.AWS_REGION }}
  • id-token: write goes on the deploy job only. The build job runs npm ci, which runs code from hundreds of packages; it has no way to get a token.
  • The role ARN isn't a secret. Knowing it gets nobody in; keeping it in secrets just keeps the account ID out of the logs.
  • Adding a GitHub environment to the job changes sub to repo:<owner>/<repo>:environment:<name>, and the trust policy stops matching. Update both together.

What this doesn't fix

Anyone who can push to main can deploy, and so can any action the deploy job runs, for the hour the credentials last. OIDC removes the stored key, not the trust in the workflow. Branch protection and pinning third-party actions to a commit narrow that. This repo does neither yet; I'm the only one who pushes to it.

The role's permissions matter more than how it's assumed. Until October 2026 this same role also deployed the photo gallery's Lambda backend with AWS SAM, so it carried a second policy with iam:CreateRole, iam:AttachRolePolicy and iam:PassRole on every resource. A workflow on main could have created a role with administrator access and assumed it. When the backend moved to its own repo, I deleted that policy, and the role went back to deploying a website.

Access key in secrets OIDC role
Stored in GitHub A key that never expires Nothing
Credentials in a run The same key, every time New ones, valid an hour
Who can use it Any workflow, any branch, or anyone who copies it Only runs on main in this repo
Rotation By hand Nothing to rotate
What it can do Whatever its policy allows Whatever its policy allows

The last row is the one to read twice.

The same idea works outside GitHub: a Raspberry Pi camera and a home server here trade a certificate for one-hour credentials instead of holding a key.