Secure AWS deploys with GitHub OIDC and Terraform

Storyie Engineering Team
7 min read

How we replaced long-lived IAM access keys with GitHub OIDC and Terraform-managed roles for Storyie's AWS deployments — no key rotation, branch-scoped permissions, and infrastructure you can audit at a glance.

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: write and the aws-actions/configure-aws-credentials@v4 step.
  • 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

AWS_ROLE_ARN secret

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_ID and AWS_SECRET_ACCESS_KEY live 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 config

Three 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:prod

Two 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 apply

2. 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.tf and it's all there.
  • Embedding setup instructions in terraform output pays off whenever you reuse the pattern in a new repository.

What tripped us up:

  • Forgetting id-token: write on 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 sub claim format is exact. A typo in the branch name in terraform.tfvars causes AssumeRoleWithWebIdentity to fail silently — the trust policy just doesn't match, so the role is not assumed. Worth double-checking with terraform plan before apply.

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 after apply?" questions.

Related Posts

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.