Deploying to AWS from GitHub Actions with OIDC Authentication

by Sloth255
GitHub ActionsAWSOIDCCI/CDSecurityIAMTerraformSAM

Deploying to AWS from GitHub Actions with OIDC Authentication

Introduction

When deploying to AWS resources from GitHub Actions, the traditional approach has been to store IAM user access keys in GitHub Secrets. However, this method has several challenges:

  • Risk of access key leakage
  • Need for regular rotation work
  • Increase in secrets to manage

OIDC (OpenID Connect) authentication solves these challenges. This article provides a detailed explanation of how to set up OIDC integration between GitHub Actions and AWS.

What is OIDC?

OIDC is an authentication protocol based on OAuth 2.0. GitHub Actions issues a token that can prove "I am a workflow from this repository" to AWS, and AWS verifies it and provides temporary credentials.

Benefits of OIDC

  • No secrets required: No need to store access keys in GitHub
  • Enhanced security: Uses only temporary credentials
  • Fine-grained permission control: Permissions can be restricted by repository or branch
  • Reduced management costs: No need for key rotation

AWS Configuration

1. Creating an IAM Identity Provider

First, create an IAM Identity Provider in the AWS Management Console.

  1. Open the IAM console
  2. Click "Identity providers" → "Add provider"
  3. Enter the following information:
Provider type: OpenID Connect
Provider URL: https://token.actions.githubusercontent.com
Audience: sts.amazonaws.com

Example with Terraform

resource "aws_iam_openid_connect_provider" "github_actions" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = [
    "sts.amazonaws.com",
  ]

  thumbprint_list = [
    "6938fd4d98bab03faadb97b34396831e3780aea1"
  ]
}

Example with AWS SAM

template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: GitHub Actions OIDC Provider

Resources:
  GitHubOIDCProvider:
    Type: AWS::IAM::OIDCProvider
    Properties:
      Url: https://token.actions.githubusercontent.com
      ClientIdList:
        - sts.amazonaws.com
      ThumbprintList:
        - 6938fd4d98bab03faadb97b34396831e3780aea1

2. Creating an IAM Role

Next, create an IAM role that GitHub Actions will assume.

Configuring the Trust Policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012: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:your-org/your-repo:*"
        }
      }
    }
  ]
}

Condition Details

You can restrict access using the token.actions.githubusercontent.com:sub value.

# Entire specific repository
repo:your-org/your-repo:*

# Specific branch only
repo:your-org/your-repo:ref:refs/heads/main

# Specific environment only
repo:your-org/your-repo:environment:production

# Pull requests only
repo:your-org/your-repo:pull_request

Example with Terraform

data "aws_iam_policy_document" "github_actions_assume_role" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github_actions.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-org/your-repo:*"]
    }
  }
}

resource "aws_iam_role" "github_actions" {
  name               = "github-actions-deploy-role"
  assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json
}

# Attach required permission policies
resource "aws_iam_role_policy_attachment" "deploy_policy" {
  role       = aws_iam_role.github_actions.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonECS_FullAccess"
}

Example with AWS SAM

template.yaml
Resources:
  GitHubActionsRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: github-actions-deploy-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Federated: !GetAtt GitHubOIDCProvider.Arn
            Action: sts:AssumeRoleWithWebIdentity
            Condition:
              StringEquals:
                token.actions.githubusercontent.com:aud: sts.amazonaws.com
              StringLike:
                token.actions.githubusercontent.com:sub: repo:your-org/your-repo:*
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonECS_FullAccess

Outputs:
  GitHubActionsRoleArn:
    Description: ARN of the GitHub Actions IAM Role
    Value: !GetAtt GitHubActionsRole.Arn
    Export:
      Name: GitHubActionsRoleArn

To deploy with SAM:

sam build
sam deploy --guided

After the initial deployment, register the output role ARN in GitHub Secrets as AWS_ROLE_ARN.

3. Attaching Permission Policies

Attach the necessary permissions for deployment to the role. For example:

  • For S3 deployment: AmazonS3FullAccess
  • For ECS deployment: AmazonECS_FullAccess
  • For Lambda: AWSLambda_FullAccess

For production environments, we recommend creating custom policies based on the principle of least privilege.

GitHub Actions Configuration

Creating a Workflow File

Create .github/workflows/deploy.yml.

name: Deploy to AWS

on:
  push:
    branches:
      - main

# Permission settings to obtain OIDC token (Important!)
permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        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-actions-deploy-role
          aws-region: ap-northeast-1

      - name: Deploy to S3
        run: |
          aws s3 sync ./dist s3://your-bucket-name --delete

      - name: Verify deployment
        run: |
          aws s3 ls s3://your-bucket-name

Important Points

1. permissions Configuration

permissions:
  id-token: write  # Required to obtain OIDC token
  contents: read   # Required to checkout repository

Without this setting, you won't be able to obtain the OIDC token and will get an error.

2. configure-aws-credentials Action Version

Use v4 or later. Older versions may not support OIDC.

uses: aws-actions/configure-aws-credentials@v4

Practical Example: Deploying a SAM Application

Here's an example of deploying a serverless application using AWS SAM.

name: Deploy SAM Application

on:
  push:
    branches:
      - main

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: ap-northeast-1
  SAM_STACK_NAME: my-sam-app

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Setup SAM CLI
        uses: aws-actions/setup-sam@v2
        with:
          use-installer: true

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: SAM Build
        run: sam build --use-container

      - name: SAM Deploy
        run: |
          sam deploy \
            --stack-name ${{ env.SAM_STACK_NAME }} \
            --capabilities CAPABILITY_IAM \
            --resolve-s3 \
            --no-fail-on-empty-changeset \
            --no-confirm-changeset

      - name: Get Stack Outputs
        run: |
          aws cloudformation describe-stacks \
            --stack-name ${{ env.SAM_STACK_NAME }} \
            --query 'Stacks[0].Outputs' \
            --output table

Practical Example: Deploying to ECS

As a more practical example, here's an ECS deployment workflow.

name: Deploy to ECS

on:
  push:
    branches:
      - main

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: ap-northeast-1
  ECR_REPOSITORY: my-app
  ECS_SERVICE: my-service
  ECS_CLUSTER: my-cluster
  CONTAINER_NAME: my-container

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push image to ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Download task definition
        run: |
          aws ecs describe-task-definition \
            --task-definition my-task \
            --query taskDefinition > task-definition.json

      - name: Update task definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task-definition.json
          container-name: ${{ env.CONTAINER_NAME }}
          image: ${{ steps.build-image.outputs.image }}

      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

Troubleshooting

Error: "Not authorized to perform sts:AssumeRoleWithWebIdentity"

Cause: Trust policy conditions don't match

Solution:

  1. Verify the token.actions.githubusercontent.com:sub value in the IAM role's trust policy
  2. Check that the repository name and branch name are correct
  3. Check the actual sub claim value in the GitHub Actions logs
- name: Debug OIDC token
  run: |
    curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | \
      jq -R 'split(".") | .[1] | @base64d | fromjson'

Error: "Error: Credentials could not be loaded"

Cause: Missing permissions configuration

Solution: Add the following to your workflow file

permissions:
  id-token: write
  contents: read

Authentication Succeeds but Permission Errors Occur

Cause: Required permission policies are not attached to the IAM role

Solution: Attach appropriate policies to the IAM role

aws iam attach-role-policy \
  --role-name github-actions-deploy-role \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess

Security Best Practices

1. Principle of Least Privilege

Grant only the minimum necessary permissions.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::your-specific-bucket",
        "arn:aws:s3:::your-specific-bucket/*"
      ]
    }
  ]
}

2. Restrict by Branch or Tag

Restrict production deployments to specific branches only.

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
    }
  }
}

3. Separate Roles by Environment

Use different roles for development, staging, and production.

- name: Configure AWS credentials (Production)
  if: github.ref == 'refs/heads/main'
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ secrets.AWS_ROLE_ARN_PROD }}
    aws-region: ap-northeast-1

- name: Configure AWS credentials (Development)
  if: github.ref == 'refs/heads/develop'
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ secrets.AWS_ROLE_ARN_DEV }}
    aws-region: ap-northeast-1

4. Audit with CloudTrail

Record all API calls and review them regularly.

# S3 bucket for CloudTrail
resource "aws_s3_bucket" "cloudtrail" {
  bucket = "my-cloudtrail-logs-bucket"
}

resource "aws_s3_bucket_policy" "cloudtrail" {
  bucket = aws_s3_bucket.cloudtrail.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AWSCloudTrailAclCheck"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:GetBucketAcl"
        Resource = aws_s3_bucket.cloudtrail.arn
      },
      {
        Sid    = "AWSCloudTrailWrite"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:PutObject"
        Resource = "${aws_s3_bucket.cloudtrail.arn}/*"
        Condition = {
          StringEquals = {
            "s3:x-amz-acl" = "bucket-owner-full-control"
          }
        }
      }
    ]
  })
}

# CloudTrail
resource "aws_cloudtrail" "github_actions_audit" {
  name                          = "github-actions-audit"
  s3_bucket_name               = aws_s3_bucket.cloudtrail.id
  include_global_service_events = true
  is_multi_region_trail        = true
  enable_logging               = true

  depends_on = [aws_s3_bucket_policy.cloudtrail]
}

Conclusion

OIDC integration between GitHub Actions and AWS provides the following benefits:

  • Secure deployment without access keys
  • Uses only temporary credentials
  • Fine-grained permission control by repository or branch
  • Reduced management costs

While the initial setup is somewhat complex, once configured, it leads to long-term improvements in security and operability. I highly recommend considering its adoption.

References