When you first wire up a GitHub Actions deployment pipeline, the path of least resistance is to create an IAM user, generate an access key, and drop it into GitHub Secrets. It works immediately. The problem is that you've just committed a permanent credential to a system you don't fully control — one that stays valid until you remember to rotate it.
We built Storyie's deployment pipeline differently. GitHub OIDC lets Actions authenticate to AWS without any long-lived keys. Terraform manages the OIDC provider, IAM role, and policies as code, so the setup is auditable, reproducible, and takes about ten minutes to rebuild from scratch if we ever need to.
TL;DR
- GitHub OIDC issues a short-lived token per workflow run; AWS validates it and exchanges it for temporary credentials.
- A Terraform module creates three resources: an OIDC provider, an IAM role with a branch-scoped trust policy, and an IAM policy with the permissions SST needs.
- The GitHub Actions workflow side is two lines:
permissions: id-token: writeand theaws-actions/configure-aws-credentials@v4step. - No key rotation. No secrets beyond the role ARN itself. The trust policy enforces which branches can deploy.
Resource | What it does |
|---|---|
OIDC provider | Registers GitHub Actions as a trusted identity provider in AWS |
IAM role | What Actions assumes; trust policy enforces repo + branch conditions |
IAM policy | The actual AWS permissions SST needs to deploy |
| The only secret — the ARN of the role above |
Why OIDC
The difference between the two approaches is the lifetime of the credential.
IAM user + access key:
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYlive in GitHub Secrets indefinitely.- If the key leaks — via a log line, a forked PR with secrets access, or a GitHub breach — the attacker has permanent AWS access until you notice and rotate.
- Rotation is a manual process that's easy to skip.
GitHub OIDC:
- GitHub issues a JWT for each workflow run, scoped to that run's repository and ref.
- AWS validates the JWT, checks the trust policy, and returns temporary credentials that expire with the session.
- There is no long-lived secret to rotate, leak, or forget.
The setup cost is higher than pasting keys into a settings page, but you pay it once. After that, you genuinely never think about credential rotation again.
Terraform module layout
terraform/github-oidc/
├── main.tf # OIDC provider + IAM role + policy
├── variables.tf # Input variables
├── outputs.tf # Role ARN + setup instructions
└── terraform.tfvars.example # Starter configThree resources, one module. The structure is intentionally minimal — this is a one-time setup, not something that scales horizontally.
The Terraform design
OIDC provider
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = [
"sts.amazonaws.com",
]
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1",
"1c58a3a8518e8759bf075b76b750d4f2df264fcd"
]
}client_id_list is set to sts.amazonaws.com — that's the audience GitHub's token targets when it requests AWS credentials. The thumbprint_list entries are the SHA-1 fingerprints of GitHub's OIDC endpoint TLS certificate. AWS uses them to verify that tokens actually came from GitHub. If GitHub rotates its certificate, these need updating — an error from the OIDC step is the signal to check.
Trust policy — which branches can deploy
This is where the security model lives.
data "aws_iam_policy_document" "github_actions_assume_role" {
statement {
effect = "Allow"
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
actions = ["sts:AssumeRoleWithWebIdentity"]
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [
for branch in var.github_branches :
"repo:${var.github_org}/${var.github_repo}:ref:refs/heads/${branch}"
]
}
}
}The sub claim in the OIDC token encodes the exact repository and branch that triggered the workflow. The condition above matches it against an explicit allowlist. Any run from a branch not in the list hits AccessDenied at the STS layer — before any AWS resource is touched.
For Storyie, only main and develop can deploy:
variable "github_branches" {
description = "List of GitHub branches allowed to assume the role"
type = list(string)
default = ["develop", "main"]
}This makes branch-based deploy guardrails automatic. Feature branches simply can't assume the role, so accidental deploys from the wrong branch aren't possible. When the allowed branch list needs to change, it's a one-line edit in terraform.tfvars and a terraform apply.
IAM policy for SST
SST (Serverless Stack) is our deployment framework for the web app. Under the hood it orchestrates CloudFormation, Lambda, S3, CloudFront, and several other services. The policy document covers what each service needs.
data "aws_iam_policy_document" "sst_deploy" {
# CloudFormation — stack management
statement {
sid = "CloudFormation"
effect = "Allow"
actions = ["cloudformation:*"]
resources = ["*"]
}
# S3 — asset delivery and SST state
statement {
sid = "S3"
effect = "Allow"
actions = ["s3:*"]
resources = ["*"]
}
# Lambda — Next.js server functions
statement {
sid = "Lambda"
effect = "Allow"
actions = ["lambda:*"]
resources = ["*"]
}
# CloudFront, Route53, ACM, SQS, DynamoDB (ISR revalidation)
# ... additional statements follow the same pattern
}We use * on resources throughout. For a production multi-team environment, scoping down to specific ARNs is the right call. For Storyie, the tradeoff goes the other way: SST creates new resources on every major version upgrade, and narrowly-scoped policies reliably break deploys at the worst possible time. A wildcard policy plus OIDC's branch restriction gives us a practical security boundary without the operational toil.
GitHub Actions configuration
jobs:
deploy:
runs-on: self-hosted
permissions:
id-token: write # Required to request an OIDC token
contents: read
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Deploy to AWS with SST
run: pnpm sst:deploy:prodTwo things to get right:
permissions: id-token: write: Without this, GitHub will not issue an OIDC token at all. The default workflow token doesn't include this permission, so it has to be declared explicitly. Omitting it produces an AccessDenied that looks like an AWS problem but is actually happening on the GitHub side.
aws-actions/configure-aws-credentials@v4: Pass the role ARN in role-to-assume and the action handles everything — requesting the OIDC token from GitHub, calling STS's AssumeRoleWithWebIdentity, and writing the resulting temporary credentials to the environment. The subsequent steps see ordinary AWS_* environment variables and don't need to know anything about OIDC.
AWS_ROLE_ARN is the only secret. It's the ARN of the role Terraform created, which you can copy from the terraform apply output.
Setup steps
1. Run Terraform
cd terraform/github-oidc
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars — set github_org and github_repo
terraform init
terraform plan
terraform apply2. Register the role ARN as a GitHub secret
The terraform apply output includes the role ARN. Add it to your repository under Settings → Secrets and variables → Actions as AWS_ROLE_ARN.
We embed setup instructions directly in the Terraform output so the steps are always in the same place:
output "github_secret_instructions" {
value = <<-EOT
1. Copy the role ARN above
2. Go to Settings → Secrets and variables → Actions
3. Name: AWS_ROLE_ARN
4. Value: (role ARN)
EOT
}When we set up a new repository that uses the same pattern, the output is the checklist — no need to reconstruct the steps from memory.
3. Push and deploy
Push to main or develop. The workflow runs, OIDC authentication succeeds, and SST deploys. The credential flow is invisible from the workflow author's perspective.
State management
This module uses local state:
backend "local" {
path = "terraform.tfstate"
}The OIDC provider and its role are created once and touched rarely. Standing up an S3 backend with DynamoDB locking for a handful of resources that one person manages felt like overengineering. Local state is simpler, and there's no contention risk when you're the only person running terraform apply.
The one thing to remember: terraform.tfstate must be in .gitignore. The file contains your AWS account ID and role ARN, which you don't want committed.
What we learned running this in production
What works well:
- Zero rotation work. Once the initial setup is done, there's nothing to maintain on the credential side. The OIDC provider just works.
- Branch restrictions are a natural guardrail. We can't accidentally deploy from a feature branch — the trust policy makes it structurally impossible, not just conventionally discouraged.
- The Terraform configuration is self-documenting. Six months later, when you wonder what permissions the deployment role has, you open
main.tfand it's all there. - Embedding setup instructions in
terraform outputpays off whenever you reuse the pattern in a new repository.
What tripped us up:
- Forgetting
id-token: writeon the first attempt. The error message points to AWS, but the fix is on the GitHub side. - Thumbprint staleness. If GitHub rotates its OIDC endpoint certificate, the thumbprints need updating. This is rare, but an OIDC validation error is the signal to check.
- The
subclaim format is exact. A typo in the branch name interraform.tfvarscausesAssumeRoleWithWebIdentityto fail silently — the trust policy just doesn't match, so the role is not assumed. Worth double-checking withterraform planbeforeapply.
Takeaways
- Replace long-lived IAM access keys with OIDC wherever you have GitHub Actions deploying to AWS. The initial setup cost is real but small; the ongoing benefit is that you never think about credential rotation again.
- Put the trust policy branch list in a Terraform variable. Adding or removing a branch is a one-line config change, not a policy edit.
- If you're using SST, wildcard resource permissions are a practical tradeoff for a solo or small-team project. For anything with multiple operators or stricter compliance requirements, tighten the resource ARNs.
- Embed operational steps (like GitHub secret registration) in
terraform output. It costs nothing and removes a class of "what do I do afterapply?" questions.
Related Posts
- Building a Monorepo with pnpm and TypeScript — the workspace and package conventions that surround this deployment setup
- Building a Cross-Platform Mobile App with Expo — our Expo build and distribution pipeline
- Next.js 16 Deployment with SST — the SST configuration that the OIDC role is deploying
Try Storyie
The deployment setup described here keeps storyie.com running. If you want to see what ships out of this pipeline, write a diary on the web or grab the iOS app — both stay in sync through the same infrastructure.