GitHub ActionsからOIDCでAWSを操作する仕組みを理解する

GitHub ActionsからAWSを操作する場面(ECRへのイメージプッシュ、S3へのデプロイ、Terraformの実行など)はよくあると思う。このとき、IAMユーザーのアクセスキーをGitHubのSecretsに保存する方法がかつては一般的だったが、現在はOIDC(OpenID Connect)を使ったキーレスな認証が推奨されている。

この記事では、以下がわかるようにまとめてみた。

  • [What] GitHub ActionsからOIDCでAWSを操作するとはどういうことか?
  • [Why] なぜアクセスキーではなくOIDCを使うべきなのか?
  • [How] TerraformとGitHub Actionsでどのように設定するのか?
  • [Deep Dive] 裏側ではどのような認証フローが動いているのか?

従来の方法とその課題

OIDCを使わない場合、GitHub ActionsからAWSを操作するには、IAMユーザーを作成してアクセスキー(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)を発行し、GitHubリポジトリのSecretsに登録することになる。

この方法には以下のような課題がある。

  • 長期クレデンシャルの保管: アクセスキーは明示的に無効化しない限り永続的に有効。漏洩すると、失効させるまでの間ずっと悪用可能な状態になる。
  • ローテーションの運用負荷: セキュリティ上、定期的なキーのローテーションが推奨されるが、手動での運用は忘れがちで、自動化するにも仕組みが必要になる。
  • 漏洩経路の多さ: Secretsに登録していても、ワークフローのログへの出力ミスや、悪意あるActionsの混入などで漏洩するリスクがゼロにはならない。

要するに「長生きする秘密情報を持つこと」自体がリスクなのである。

OIDCを使うと何が嬉しいのか

OIDCを使うと、GitHub Actionsのワークフロー実行時にその場で発行される短命なトークンを使ってAWSのIAMロールをAssumeし、一時的なクレデンシャルを取得できる。

  • 保管すべき秘密情報がない: GitHubのSecretsにAWSのクレデンシャルを一切保存しなくてよい。漏洩するものがそもそも存在しない。
  • クレデンシャルが短命: 取得される一時クレデンシャルには有効期限があり、ジョブが終われば自然に失効する。
  • 細かいアクセス制御: 「このリポジトリの、このブランチからの実行のみ許可」といった条件をIAMロールの信頼ポリシーで柔軟に設定できる。

OIDCそのものの仕組みについては、別記事「OIDCとは」に詳しくまとめている。

全体像

登場人物と関係は以下の通り。

  1. GitHub OIDCプロバイダーtoken.actions.githubusercontent.com): ワークフロー実行時に、そのジョブの情報(リポジトリ、ブランチなど)を含むJWT(IDトークン)を発行する。
  2. AWS IAM OIDCプロバイダー: 「GitHubのOIDCプロバイダーを信頼する」ことをAWS側に登録するリソース。
  3. IAMロール: GitHub Actionsに引き受けさせたいロール。信頼ポリシーで「どのリポジトリ・ブランチからのトークンなら引き受けを許可するか」を定義する。
  4. AWS STS: JWTを検証し、問題なければ一時クレデンシャルを発行する。

Terraformでの設定

AWS側の設定をTerraformで書いていく。必要なのは「OIDCプロバイダーの登録」と「信頼ポリシー付きIAMロールの作成」の2つ。

OIDCプロバイダーの登録

# GitHubのOIDCプロバイダーをAWSに登録
resource "aws_iam_openid_connect_provider" "github_actions" {
  url = "https://token.actions.githubusercontent.com"

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

client_id_list には sts.amazonaws.com を指定する。これは後述するJWTの aud(audience)クレームと突き合わせられる値である。

なお、以前は thumbprint_list にGitHubのTLS証明書のサムプリントを指定する必要があったが、2023年のアップデート以降、AWSは信頼されたCAが発行した証明書を使うプロバイダーについては登録されたサムプリントに依存せず検証を行うようになったため、現在は実質的に気にしなくてよい。(公式ドキュメント参照)

IAMロールと信頼ポリシー

# GitHub ActionsがAssumeするIAMロール
resource "aws_iam_role" "github_actions" {
  name = "github-actions-deploy"

  assume_role_policy = data.aws_iam_policy_document.github_actions_assume.json
}

# 信頼ポリシー: 特定リポジトリのmainブランチからのみ許可
data "aws_iam_policy_document" "github_actions_assume" {
  statement {
    effect  = "Allow"
    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     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:ryuichi-maeda/my-app:ref:refs/heads/main"]
    }
  }
}

# ロールに必要な権限を付与(例: ECRへのプッシュ権限)
resource "aws_iam_role_policy_attachment" "github_actions_ecr" {
  role       = aws_iam_role.github_actions.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser"
}

ポイントは信頼ポリシーの2つの condition である。

  • aud(audience): トークンの発行先が sts.amazonaws.com であること。
  • sub(subject): トークンの発行元が「ryuichi-maeda/my-app リポジトリの main ブランチ」であること。

特に sub の条件が重要で、これを repo:ryuichi-maeda/*:* のように緩くしてしまうと、自分の全リポジトリ(さらに雑に書けば他人のリポジトリまで)からロールをAssumeできてしまう。最小限のリポジトリ・ブランチに絞るのが鉄則である。

条件演算子は、この例のように完全一致で絞るなら StringEquals を使う。repo:ryuichi-maeda/my-app:ref:refs/heads/release/* のようにワイルドカードで範囲指定したい場合のみ StringLike に変える。完全一致のつもりなのに StringLike を使っていると、将来値に * が紛れ込んだときに意図せず条件が広がってしまうので注意。

sub クレームの形式はトリガーによって変わる点にも注意。

トリガーsubの形式
ブランチへのpushrepo:<owner>/<repo>:ref:refs/heads/<branch>
タグへのpushrepo:<owner>/<repo>:ref:refs/tags/<tag>
Pull Requestrepo:<owner>/<repo>:pull_request
Environment指定時repo:<owner>/<repo>:environment:<environment>

GitHub Actions側の設定

GitHub側では、公式の aws-actions/configure-aws-credentials を使う。

name: Deploy

on:
  push:
    branches:
      - main

permissions:
  id-token: write # OIDCトークンの取得に必須
  contents: read

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-actions-deploy
          aws-region: ap-northeast-1

      # 以降のステップではAWS CLIやSDKがそのまま使える
      - name: Check identity
        run: aws sts get-caller-identity

ハマりどころは permissions の指定である。id-token: write がないとOIDCトークンを取得できず、認証に失敗する。ジョブレベルまたはワークフローレベルのどちらかで必ず指定する必要がある。

これだけで、Secretsに一切クレデンシャルを置かずにAWSを操作できるようになる。

裏側の仕組み

ここからが本題。configure-aws-credentials を1ステップ挟むだけで認証が通るのは便利だが、裏側では何が起きているのか。流れを順に追ってみる。

1. GitHubがジョブごとにOIDCトークンを発行できる状態にする

ワークフローの permissionsid-token: write が指定されていると、GitHubはジョブの実行環境に以下の環境変数を注入する。

  • ACTIONS_ID_TOKEN_REQUEST_URL: OIDCトークンを取得するためのエンドポイントURL
  • ACTIONS_ID_TOKEN_REQUEST_TOKEN: そのエンドポイントを叩くためのBearerトークン

2. configure-aws-credentialsがOIDCトークン(JWT)を取得する

configure-aws-credentials は、上記のエンドポイントに audience=sts.amazonaws.com を付けてリクエストし、JWTを受け取る。このJWTのペイロードには、ジョブのコンテキスト情報がクレームとして含まれている。

{
  "iss": "https://token.actions.githubusercontent.com",
  "aud": "sts.amazonaws.com",
  "sub": "repo:ryuichi-maeda/my-app:ref:refs/heads/main",
  "repository": "ryuichi-maeda/my-app",
  "ref": "refs/heads/main",
  "workflow": "Deploy",
  "exp": 1784091000,
  ...
}
  • iss(issuer): トークンの発行者。GitHubのOIDCプロバイダーを指す。
  • aud(audience): トークンの想定利用先。ここでは sts.amazonaws.com
  • sub(subject): 「どのリポジトリの、どのref(ブランチ等)か」を表す。信頼ポリシーの条件と突き合わせられるのはこの値。

このJWTはGitHubの秘密鍵で署名されており、有効期限も短い。

3. AWS STSにJWTを渡してロールのAssumeを要求する

次に configure-aws-credentials は、受け取ったJWTを添えてAWS STSの AssumeRoleWithWebIdentity APIを呼び出す。手動でやるなら以下のようなイメージ。

aws sts assume-role-with-web-identity \
  --role-arn arn:aws:iam::123456789012:role/github-actions-deploy \
  --role-session-name GitHubActions \
  --web-identity-token "$OIDC_JWT"

注目すべきは、このAPI呼び出し自体にはAWSのクレデンシャルが不要という点。認証の材料はJWTそのものである。

4. AWS側でJWTを検証する

STSは受け取ったJWTを以下の観点で検証する。

  1. 署名の検証: JWTの iss に対応するOIDCプロバイダー(token.actions.githubusercontent.com)の公開鍵を、JWKSエンドポイント(プロバイダーが /.well-known/openid-configuration で公開している jwks_uri)から取得し、署名が正しいか確認する。これにより「このトークンは確かにGitHubが発行したもので、改ざんされていない」ことが保証される。
  2. 有効期限の確認: exp クレームを見てトークンが失効していないか確認する。
  3. 信頼ポリシーとの突き合わせ: audsub などのクレームが、IAMロールの信頼ポリシーの condition を満たすか確認する。

つまり、Terraformで書いた信頼ポリシーの条件は、この段階でJWTのクレームと照合されている。「subrepo:ryuichi-maeda/my-app:ref:refs/heads/main に一致するか」という条件は、「GitHubが署名付きで証明したジョブの出自」に対するチェックというわけである。

5. 一時クレデンシャルが発行される

検証をすべて通過すると、STSは一時クレデンシャル(AccessKeyId / SecretAccessKey / SessionToken)を返す。configure-aws-credentials はこれらを環境変数(AWS_ACCESS_KEY_ID など)としてジョブに設定するため、以降のステップではAWS CLIやSDKが透過的にこのクレデンシャルを使える。

この一時クレデンシャルの有効期限はデフォルトで1時間。ジョブが終われば使い道もなく、仮に漏洩しても被害は限定的である。

フローのまとめ

sequenceDiagram
    participant Job as GitHub Actions(ジョブ)
    participant GH as GitHub OIDCプロバイダー
    participant STS as AWS STS
    participant AWS as AWSの各サービス

    Job->>GH: (1) トークン要求
    GH-->>Job: (2) JWT(署名付き)
    Job->>STS: (3) AssumeRoleWithWebIdentity(JWTを添付)
    STS->>GH: (4) 公開鍵の取得
    Note over STS: 署名検証・信頼ポリシー照合
    STS-->>Job: (5) 一時クレデンシャル
    Job->>AWS: 以降、一時クレデンシャルでAWSを操作

一連の流れを見ると、「GitHubが署名したジョブの身分証明書(JWT)を、AWSが事前に登録した信頼関係と照合して、短命な入館証(一時クレデンシャル)に引き換えている」と捉えるとわかりやすいと思う。

まとめ

  • GitHub ActionsからAWSを操作するなら、長期アクセスキーではなくOIDCを使うのが現在の推奨。保管すべき秘密情報がなくなり、クレデンシャルも短命になる。
  • AWS側の設定はTerraformで「OIDCプロバイダーの登録」と「信頼ポリシー付きIAMロール」を書くだけ。sub の条件は最小限のリポジトリ・ブランチに絞ること。
  • GitHub側は permissions: id-token: writeconfigure-aws-credentials の2点セット。
  • 裏側では「GitHubによるJWTの発行 → STSでの署名検証・信頼ポリシー照合 → 一時クレデンシャルの発行」というトークン交換のフローが動いている。

参考文献

Configuring OpenID Connect in Amazon Web Services - GitHub Docs

About security hardening with OpenID Connect - GitHub Docs

aws-actions/configure-aws-credentials - GitHub

AssumeRoleWithWebIdentity - AWS Security Token Service

Terraform Registry - aws_iam_openid_connect_provider