From IAM Keys to OIDC: Securing GitHub Actions with AWS
If you have ever set up a GitHub Actions pipeline that deploys to AWS, you've probably done the same thing I did first - created an IAM user, generated an access key, and pasted it into your repo secrets. It works, but it's not great. Those keys never expire, they sit in your GitHub secrets indefinitely, and if they leak you have a problem.
There's a better way: OIDC (OpenID Connect). No keys stored anywhere. GitHub generates a short-lived token per workflow run, AWS validates it, and hands back temporary credentials that expire after an hour.
Here's how I set it up for my Headscale project.
How it works
When a GitHub Actions job runs, GitHub generates a JWT (JSON Web Token) signed by GitHub's OIDC provider. The token contains claims about the run - things like which repo triggered it, which branch, which workflow. AWS checks those claims against an IAM role's trust policy, and if they match, issues temporary credentials via STS.
The key part: no secrets stored anywhere. The token is generated fresh each run, and the trust policy scopes it tightly to your specific repo and branch.
Step 1 - Register GitHub as a trusted identity provider
First, AWS needs to know to trust tokens from GitHub. This is a one-time setup per AWS account.
data "tls_certificate" "github" {
url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint]
}
data "tls_certificate" fetches GitHub's certificate so Terraform can compute the thumbprint AWS needs to validate incoming tokens. client_id_list = ["sts.amazonaws.com"] restricts this provider to role assumption via STS only.
Step 2 - Create the IAM role with a scoped trust policy
data "aws_iam_policy_document" "github_actions_assume_role" {
statement {
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:your-username/your-repo:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "github_actions_deploy" {
name = "your-prefix-github-actions-deploy"
assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json
}
The two conditions are what actually protect this role:
aud(audience) - must equalsts.amazonaws.com. Always required for AWS OIDC.sub(subject) - scopes the token to your specific repo and branch. Only a workflow running onmainin your repo can assume this role. Changemainto*if you want to allow any branch, but tighter is better.
Step 3 - Attach a least-privilege permissions policy
data "aws_caller_identity" "current" {}
data "aws_iam_policy_document" "github_actions_deploy_permissions" {
statement {
sid = "ECRAuth"
actions = ["ecr:GetAuthorizationToken"]
resources = ["*"]
}
statement {
sid = "ECRPush"
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
]
resources = ["arn:aws:ecr:\({var.aws_region}:\){data.aws_caller_identity.current.account_id}:repository/your-repo-name"]
}
statement {
sid = "ECSDeploy"
actions = [
"ecs:UpdateService",
"ecs:DescribeServices",
]
resources = ["arn:aws:ecs:\({var.aws_region}:\){data.aws_caller_identity.current.account_id}:service/your-cluster/your-service"]
}
}
resource "aws_iam_role_policy" "github_actions_deploy" {
name = "github-actions-deploy-policy"
role = aws_iam_role.github_actions_deploy.id
policy = data.aws_iam_policy_document.github_actions_deploy_permissions.json
}
ecr:GetAuthorizationToken needs resource = "*" because it's an account-level call, not scoped to a specific repo. Everything else is scoped to the exact ECR repo and ECS service. data.aws_caller_identity.current pulls your account ID at plan time so you're not hardcoding it.
Step 4 - Update your deploy workflow
Replace the access key credentials block with this:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<account-id>:role/your-prefix-github-actions-deploy
aws-region: your-region
permissions: id-token: write is required - it tells GitHub to generate an OIDC token for this job. Without it, the token is never created and the role assumption fails.
Before and after
| Access keys | OIDC | |
|---|---|---|
| Stored credentials | Yes - in GitHub secrets | None |
| Credential lifetime | Until manually rotated | ~1 hour per run |
| Scope | Account-level (as broad as the policy) | Repo and branch specific |
| Leaked credential risk | Permanent until rotated | Expires fast, scoped tightly |
Cleanup
Once OIDC is working, delete the old IAM user and remove the AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY secrets from your GitHub repo. You don't need them anymore.
