Keyless deploys: GitHub Actions into AWS with no stored secrets
Part of my Krabber series, a Twitter clone in Go. The full source is on GitHub.
Intro
For years the default way to let CI touch AWS was to mint an IAM access key, paste it into your CI secrets, and hope it never leaks. That key is long-lived, it’s a static secret sitting in a second system, and if it gets out, someone has your account until you notice and rotate it. Krabber has none of those. Its GitHub Actions authenticate to AWS with zero stored credentials, using OIDC, and I want to show how that’s set up because it’s one of those security wins that’s also just less hassle.
I. The idea
GitHub can act as an OpenID Connect identity provider. When a workflow runs, GitHub will hand it a short-lived signed token that says, in effect, “this is run X, on repo t0ul/krabber-net, on branch main.” AWS can be told to trust that provider, and to let a specific token assume a specific role. The role hands back temporary credentials that expire in minutes.
So nothing static is stored anywhere. No access key in GitHub, no secret to rotate, nothing to leak. The trust is a relationship between GitHub and AWS, not a password.
II. The setup
First, register GitHub as an OIDC provider in IAM, once:
IAM → Identity providers → token.actions.githubusercontent.com
Then create an IAM role whose trust policy only accepts tokens from my repo, on the branch I deploy from:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::<account-id>:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:t0ul/krabber-net:ref:refs/heads/main"
}
}
}
That sub condition is the whole security boundary. Only a workflow running on main of my repo can assume this role. A pull request can’t (its sub is different), and crucially GitHub doesn’t even issue OIDC tokens to pull requests from forks, so a stranger opening a PR can’t get in.
The workflow side is tiny. You grant the job permission to request a token and then let the AWS action trade it for credentials, with no keys anywhere:
permissions:
id-token: write # lets the job request the OIDC token
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<account-id>:role/krabber-gha-deploy
aws-region: us-east-2
III. Two roles, least privilege
I actually use two roles, because a pull request and a deploy should be allowed to do very different things.
The plan role is trusted by pull requests. It’s read-only (plus just enough to write Terraform’s state lock and decrypt the config parameters), because a PR should be able to show me what would change, and nothing more.
The deploy role is trusted only by pushes to main. It can manage the handful of services Krabber actually uses, all scoped to names starting with krabber-. It explicitly can’t create or change IAM, and it can only pass the three workload roles the app already uses. So even the deploy path can’t quietly grant itself more power.
That split means the dangerous permissions only exist on the branch that’s already protected, and the PR path a stranger might reach is read-only.
Conclusion
Keyless CI is one of those changes that makes your setup both safer and simpler at the same time, which is rare. There’s no access key to leak because there is no access key. The trust is pinned to one repo and one branch, forks are shut out by design, and the two roles make sure a pull request can look but not touch. If you’re still pasting AWS keys into CI secrets, OIDC is an afternoon of work that deletes a whole category of worry. Thanks for reading, and may your credentials always be short-lived.