Skip to main content

Command Palette

Search for a command to run...

From IAM Keys to OIDC: Securing GitHub Actions with AWS

Updated
4 min read
S
DevOps & Cloud Engineer. Building on AWS, containerising things, occasionally breaking prod. Writing about infrastructure, automation, and the stuff I wish someone had documented better.

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 equal sts.amazonaws.com. Always required for AWS OIDC.
  • sub (subject) - scopes the token to your specific repo and branch. Only a workflow running on main in your repo can assume this role. Change main to * 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.