GitHub Deploys to AWS, OIDC for simplicity and security
Many of us start out pasting secret keys in to GitHub secrets in order to automate deploys, but there’s a better way. OpenID Connect (OIDC) allows GitHub to create it’s own tokens within actions and workflow, and AWS can be configured to allow those tokens specific permissions.
This means less work for you, and no secret keys to be copied, rotated, and potentially leaked.
One OIDC Indentiry Provider to rule them all
AWS only allows a single OIDC Identity Provider per issuer (i.e. GitHub), per account.
So assuming you only have one AWS account, you can set it up once and use it across all your projects.
You probably don’t want to include the OIDC provider resource in the Terraform or IaC for any individual project. Instead create it once and just reference it in each project you want to set up.
Check if one already exists
Before creating anything, check whether a provider for GitHub already exists in your account:
aws iam list-open-id-connect-providers
If one exists, you’ll get back a list of ARNs. To inspect it:
aws iam get-open-id-connect-provider \
--open-id-connect-provider-arn arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com
This will show the URL, client ID list, and thumbprint. If token.actions.githubusercontent.com is already there, you’re done — skip straight to creating an IAM role.
Create the provider
If it doesn’t exist yet, create it:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
--url— the issuer URL for GitHub’s OIDC service. This is what uniquely identifies the provider in AWS.--client-id-list— the “audience” that tokens must be issued for. Usingsts.amazonaws.comis the standard here and matches what theconfigure-aws-credentialsaction requests.--thumbprint-list— the TLS certificate thumbprint for the issuer URL. AWS uses this to verify it’s talking to the real GitHub OIDC endpoint. The thumbprint above is correct as of writing, but if you want to verify it yourself, GitHub documents the current value in their OIDC docs.
This only needs to be done once per AWS account.
Create an IAM role
Each project or workflow needs its own IAM role that GitHub’s identity token can assume. The trust policy is where you control which repos, branches, or environments are allowed to assume the role.
Since the OIDC provider is shared infrastructure, the role itself belongs in each project’s own Terraform. Use data.aws_caller_identity to avoid hardcoding the account ID:
data "aws_caller_identity" "current" {}
resource "aws_iam_role" "github_deploy" {
name = "github-deploy-my-repo"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/token.actions.githubusercontent.com"
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}
]
})
}
The sub claim is how AWS knows which GitHub repo is allowed to assume this role — more on that in the security section below.
Attach permissions
The role needs whatever permissions your workflow actually requires. Don’t reach for AdministratorAccess — give it only what it needs.
Here’s an example scoped to uploading files to S3 and updating a Lambda function’s code:
resource "aws_iam_policy" "github_deploy" {
name = "github-deploy-my-repo"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:PutObject", "s3:DeleteObject"]
Resource = "arn:aws:s3:::my-bucket/*"
},
{
Effect = "Allow"
Action = "lambda:UpdateFunctionCode"
Resource = "arn:aws:lambda:eu-west-1:${data.aws_caller_identity.current.account_id}:function:my-function"
}
]
})
}
resource "aws_iam_role_policy_attachment" "github_deploy" {
role = aws_iam_role.github_deploy.name
policy_arn = aws_iam_policy.github_deploy.arn
}
GitHub Actions workflow
On the GitHub side, the workflow needs two things: permission to request an OIDC token, and a step that exchanges it for AWS credentials.
name: Deploy
on:
push:
branches:
- main
permissions:
id-token: write # required to request the OIDC token
contents: read # required to checkout the repo
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy-my-repo
aws-region: eu-west-1
- name: Deploy
run: |
# your deployment commands here
The configure-aws-credentials action handles the OIDC token exchange and sets the standard AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN environment variables for the rest of the job. The credentials are short-lived — they expire after an hour or so by default.
Security Checklist
Scope the sub condition
This is important. Without a Condition block restricting the sub claim, any GitHub Actions workflow in any repo could assume your role (as long as they know its ARN, which isn’t secret). Always lock it down to at least a specific repo.
The sub claim format varies depending on what triggered the workflow:
# Any event in a repo
repo:my-org/my-repo:*
# A specific branch
repo:my-org/my-repo:ref:refs/heads/main
# A specific GitHub environment
repo:my-org/my-repo:environment:production
# A specific tag
repo:my-org/my-repo:ref:refs/tags/v1.2.3
Using StringLike (as in the example above) lets you use wildcards. StringEquals requires an exact match. For a branch-triggered deploy you probably want StringEquals to avoid unintended matches.
Don’t give the role too much access
It’s tempting to give the deploy role broad access, but that means a compromised (or more likely buggy 😅) workflow can do anything in your account. Scope it to what the deploy actually needs.
The role ARN isn’t a secret
You don’t need to store it as a GitHub secret. Hardcode it in the workflow YAML or set it as a non-secret environment variable. Obscuring the role ARN adds no real security — the trust policy conditions are what protect it.