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: writegoes on the deploy job only. The build job runsnpm 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
subtorepo:<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.