GitHub ActionsからAWSを操作するとき、いまだに AWS_ACCESS_KEY_ID と AWS_SECRET_ACCESS_KEY をリポジトリのSecretsに登録していませんか。筆者も以前はそうしていたのですが、長期アクセスキーは「漏れたら終わり」の代物で、ローテーションの手間も地味に重くのしかかります。最近、実際のプロジェクトでCI/CD基盤を構築した際に、OIDC(OpenID Connect)によるキーレス認証へ全面的に切り替えました。やってみると最小構成は驚くほど小さく、TerraformのコードにするとIAMリソース数個で済みます。さらにTerraform 1.10以降ならstateロック用のDynamoDBテーブルすら不要になっており、「昔のベストプラクティス」がだいぶ軽量化されていることも実感しました。この記事では、実体験をもとに、GitHub Actions→AWSのOIDCキーレス認証を最小構成で組む手順を、コピペで動くコード付きで解説します。
[目次を開く]
1. なぜ長期アクセスキーをやめるのか
IAMユーザーの長期アクセスキーをCIに置く方式には、構造的な弱点があります。
- 漏洩したら即アウト: キー文字列そのものが認証情報なので、ログへの誤出力やフォークPRからの竊取で漏れると、失効させるまで攻撃者が使い放題になります
- ローテーションが人力: 定期的にキーを作り直してSecretsを更新する運用は、忘れられがちで属人化しやすいです
- 権限が固定化しがち: 「とりあえずAdministratorAccess」で作ったキーがそのまま残り続けるケースを何度も見てきました
OIDC認証に切り替えると、リポジトリに置くのはロールのARN(ただの識別子で秘密情報ではない)だけになります。認証のたびに数十分で失効する一時クレデンシャルが発行されるため、「盗まれて長期間悪用される」というリスクのクラス自体が消えます。
2. OIDCキーレス認証の仕組み
流れはシンプルです。GitHub Actionsのジョブは実行時に、GitHubが署名したIDトークン(そのジョブがどのリポジトリ・どのブランチから実行されたかを示すJWT)を受け取れます。AWS IAM側でGitHubのOIDCプロバイダを「信頼するID発行元」として登録しておくと、AWS STSがこのIDトークンを検証し、一時クレデンシャルに交換してくれます。
sequenceDiagram
participant GHA as "GitHub Actionsジョブ"
participant IdP as "GitHub OIDCプロバイダ"
participant TokenSvc as "AWS STS"
participant Cloud as "AWSリソース"
GHA->>IdP: "IDトークンを要求(id-token: write)"
IdP-->>GHA: "IDトークン発行(sub=repo:org/repo:ref:...)"
GHA->>TokenSvc: "AssumeRoleWithWebIdentity(IDトークン提示)"
TokenSvc->>TokenSvc: "信頼ポリシーでaud/sub条件を検証"
TokenSvc-->>GHA: "一時クレデンシャル(短時間で失効)"
GHA->>Cloud: "terraform plan / AWS CLI を実行" ここで安全性の要になるのが、IAMロールの信頼ポリシーに書く sub 条件です。repo:<OWNER>/<REPO>:ref:refs/heads/main のように、どのリポジトリのどのブランチからのトークンだけを信頼するかを絞り込みます。ここをワイルドカードで全リポジトリ許可にしてしまうと、他人のリポジトリからあなたのAWSロールを引き受けられる大穴になるので、必ず絞ってください。
3. 最小構成のTerraformコード
筆者が実際に組んだ構成は、次の3ステップで完結しました。
- OIDCプロバイダとCI用IAMロールをTerraformリソースとして定義する
- 初回だけローカルから管理者クレデンシャルで
terraform applyする - リポジトリ変数
AWS_ROLE_ARNにロールARNを設定する
これだけで、以後のCIはキーレスで terraform plan まで通ります。「鶏と卵」問題(CI用ロールを作るための認証がない)は、初回のローカルapplyで解決するのがいちばん素直です。
# github-oidc.tf
# GitHubのOIDCプロバイダを登録(AWSアカウントに1つあればよい)
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
# 信頼ポリシー: 特定リポジトリ・特定ブランチのトークンだけを信頼する
data "aws_iam_policy_document" "github_actions_assume" {
statement {
effect = "Allow"
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:<OWNER>/<REPO>:ref:refs/heads/main", "repo:<OWNER>/<REPO>:pull_request"]
}
}
}
resource "aws_iam_role" "github_actions" {
name = "github-actions-ci"
assume_role_policy = data.aws_iam_policy_document.github_actions_assume.json
}
# planのみのCIなら「読み取り専用+自プロジェクトのstateのみ読み書き」で足りる
resource "aws_iam_role_policy_attachment" "readonly" {
role = aws_iam_role.github_actions.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
resource "aws_iam_role_policy" "tfstate_access" {
name = "tfstate-access"
role = aws_iam_role.github_actions.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
Resource = "arn:aws:s3:::<STATE_BUCKET>/myproject/*"
},
{
Effect = "Allow"
Action = ["s3:ListBucket"]
Resource = "arn:aws:s3:::<STATE_BUCKET>"
}
]
})
}
output "github_actions_role_arn" {
value = aws_iam_role.github_actions.arn
} <OWNER>/<REPO>・<STATE_BUCKET> はご自身の環境に置き換えてください。applyが通ったら、出力されたロールARNをGitHubリポジトリの Settings → Secrets and variables → Actions → Variables で AWS_ROLE_ARN として登録します。ARNは秘密情報ではないのでSecretsでなくVariablesで問題ありません。
thumbprint_list について: AWSは現在GitHubのOIDCについて信頼連鎖を自前で検証するため、この値は実質的に参照されなくなっています(指定しても害はありません)。プロバイダのバージョンによっては省略できる場合もあるので、エラーになるようなら直近のドキュメントを確認してください。 ハマりどころをひとつ。providers.tf に profile を書かないことです。CIはOIDCの一時クレデンシャルで動くため、providerにローカル用のprofileがハードコードされているとCIで認証エラーになります。ローカルで実行するときは環境変数 AWS_PROFILE で渡す、と役割分担するのがきれいでした。
4. ワークフローYAML
CI側はこれだけです。ポイントは permissions: id-token: write(これがないとIDトークンを取得できません)と、aws-actions/configure-aws-credentials にロールARNを渡す部分です。
# .github/workflows/terraform-plan.yml
name: terraform-plan
on:
pull_request:
paths:
- "terraform/**"
permissions:
id-token: write # OIDCトークン取得に必須
contents: read
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ap-northeast-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.10.5"
- run: terraform init
- run: terraform plan -input=false アクセスキーの記述がどこにもないことに注目してください。CI/CDパイプライン自体の考え方は「CI/CDとは?」で解説しているので、そちらも参考にどうぞ。
5. 権限は最小から始めて段階的に広げる
今回の構成で意識したのは「CIが今やることに必要な権限だけを与える」ことです。CIの仕事が terraform plan だけの段階なら、ロールの権限は前述のとおり ReadOnlyAccess+自プロジェクトのstate keyの読み書きのみで足ります。planはリソースの現状を読んでstateと比較するだけなので、書き込み権限はstateファイル以外に不要なのです。
将来 terraform apply をCIに任せるジョブを足す段階になったら、そのとき初めて作成・変更系の権限を追加します。最初からAdministratorAccessを付けてしまうより、「必要になったら広げる」設計のほうが、事故ったときの被害範囲を常に最小に保てます。
また、複数プロジェクトが同居する共有AWSアカウントでは、stateのkeyを myproject/* のようにプロジェクトごとのprefixで分離し、ロールからは自分のprefixだけに読み書きを許可するのがおすすめです。他プロジェクトのstate(そこには機密値が含まれがちです)へCIから触れない構造にできます。
6. おまけ: Terraform 1.10でDynamoDBロックテーブルが不要になった
S3バックエンドでstateを管理する場合、従来はロック用にDynamoDBテーブルを別途作るのが定番でした。しかしTerraform 1.10以降はS3ネイティブのロックファイル方式が使えるようになり、backend "s3" に use_lockfile = true を1行足すだけで済みます。
terraform {
required_version = ">= 1.10"
backend "s3" {
bucket = "<STATE_BUCKET>"
key = "myproject/terraform.tfstate"
region = "ap-northeast-1"
use_lockfile = true # S3ネイティブロック。DynamoDB不要
}
} 既存構成からの移行は、dynamodb_table = "..." の行を削除して use_lockfile = true に置き換え、terraform init -reconfigure を実行するだけでした。管理リソースがひとつ減り、DynamoDBのコストもゼロになります。今回のプロジェクトでは最初からDynamoDBなしで構築でき、「ロックテーブルの作成」という初期構築の一手間がまるごと消えたのが地味にうれしいポイントでした。S3自体の基礎は「AWS S3とは?」で解説しています。
7. よくある質問
Q. OIDCプロバイダはリポジトリごとに作る必要がありますか?
いいえ、token.actions.githubusercontent.com のOIDCプロバイダはAWSアカウントに1つあれば十分です。リポジトリごとの制御は、IAMロールの信頼ポリシーの sub 条件で行います。複数リポジトリからCIを回す場合は、リポジトリごとにロールを分けるか、1つのロールの sub 条件に複数エントリを列挙します。
Q. プルリクエストのCIでも動きますか?
動きます。ただしPR起点のジョブではIDトークンの sub が repo:<OWNER>/<REPO>:pull_request の形式になるため、信頼ポリシー側にこのパターンも許可しておく必要があります(本文のコードには含めてあります)。mainブランチのpushだけ許可したい場合は ref:refs/heads/main のみに絞ってください。
Q. 一時クレデンシャルの有効期限はどれくらいですか?
configure-aws-credentials のデフォルトでは1時間です。ジョブが終われば使い捨てになるので、仮にログへ漏れても長期キーのような致命傷にはなりません。長時間ジョブの場合は role-duration-seconds で延長できます(ロール側の最大セッション時間の範囲内)。
Q. 信頼ポリシーの sub をワイルドカードにするとどうなりますか?
repo:* のような指定は、GitHub上の任意のリポジトリからあなたのロールを引き受けられることを意味し、極めて危険です。最低でもorg(またはユーザー名)単位、できればリポジトリ・ブランチ単位まで絞るのが原則です。
8. まとめ
- GitHub Actions→AWSのOIDCキーレス認証は、「OIDCプロバイダ+IAMロール+信頼ポリシー」をTerraformで定義し、初回だけローカルでapplyして、ロールARNをリポジトリ変数に置く3ステップの最小構成で動き始める
- 長期アクセスキーの漏洩・ローテーションという運用リスクがまるごと消える
- 信頼ポリシーの
sub条件をリポジトリ・ブランチ単位まで絞るのが安全性の要。ワイルドカードは厳禁 - 権限は「planのみなら読み取り専用+state」から始め、applyを任せる段階で広げる
- Terraform 1.10以降は
use_lockfile = trueでDynamoDBロックテーブルが不要。初期構築がさらに身軽に
これからCIのAWS認証を組む方は、最初からOIDCで始めることを強くおすすめします。
