AWS

GitHub Actions から AWS に入るのに、アクセスキーを配るのをやめた

左の実行側から右のクラウド側へ、その場限りの通行証が渡されて消えていく様子を表した図

GitHub Secrets に置いた鍵は、置きっぱなしになる

CI から AWS を触るとき、いちばん手っ取り早いのは IAM ユーザーを作ってアクセスキーを発行し、GitHub Secrets に登録することです。5分で終わります。

ただ、これをやると鍵が増えます。しかも、増えた鍵には次の性質があります。

  • 失効しない。期限がないので、放っておくと何年でも有効なまま
  • どこに配ったか追いにくい。Secrets に入れたのか、手元の ~/.aws/credentials にも残っているのか、他の誰かに渡したのか
  • 漏れても気づけない。使われても正規の呼び出しと区別がつかない

3つ目が特に厄介です。パスワードと違って「ログイン通知」のようなものがないので、静かに使われても分かりません。

OIDC を使うと、この鍵そのものをなくせます。

OIDC にすると何が変わるか

仕組みはこうです。

GitHub Actions が実行される
   ↓ GitHub が「このリポジトリの、このブランチのワークフローです」という
     署名付きのトークンを発行する
   ↓ そのトークンを AWS に渡す
   ↓ AWS が「確かに GitHub が発行したものだ」と検証し、
     信頼ポリシーの条件と照合する
   ↓ 条件に合えば、短命の資格情報を返す

要点は、保管する鍵がなくなることです。実行のたびに一時的な資格情報が発行され、ジョブが終われば失効します。GitHub Secrets に AWS のキーを置く必要がありません。

漏れたときの被害も小さくなります。長期キーが漏れれば恒久的に使われますが、一時的な資格情報は時間が経てば無効になります。

設定は3つ

1. AWS 側に OIDC プロバイダを作る

「GitHub が発行したトークンを信じる」という宣言です。アカウントに1つあれば足ります。プロバイダの URL は token.actions.githubusercontent.com、対象者(audience)は sts.amazonaws.com です。

2. Assume されるロールを作る

CI が引き受けるロールを作り、信頼ポリシーでどのリポジトリからの Assume を許すかを書きます。ここが後述する要注意ポイントです。

3. ワークフローに2行足す

permissions:
  id-token: write   # OIDC トークンの発行に必要
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::<アカウントID>:role/<ロール名>
          aws-region: ap-northeast-1

      - name: Deploy
        run: npm run deploy

permissionsid-token: write を忘れると、トークンが発行されず認証に失敗します。ここは最初にハマりやすいところです。

あとは以降のステップで、普通に AWS CLI や CDK を叩けます。キーの記述は一切ありません。

信頼ポリシーの絞り方が本題

ロールの信頼ポリシーは、だいたいこういう形になります。

{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::<アカウントID>:oidc-provider/token.actions.githubusercontent.com"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:<オーナー>/<リポジトリ>:ref:refs/heads/main"
    }
  }
}

sub(subject)が「誰からのトークンか」を表しています。ここをどこまで絞るかが、そのまま安全性になります。

書き方何を許すか
repo:オーナー/*その組織の全リポジトリ・全ブランチ。危険
repo:オーナー/リポジトリ:*そのリポジトリの全ブランチ・全イベント
repo:オーナー/リポジトリ:ref:refs/heads/mainmain ブランチからのみ
repo:オーナー/リポジトリ:environment:production指定した Environment からのみ

組織全体をワイルドカードで許すのが一番まずい形です。組織内のどれか1つのリポジトリが侵害されれば、そこから本番のAWSに手が届きます。テスト用に作った小さなリポジトリが踏み台になり得ます。

記事を書きながら、自分の設定が緩いことに気づいた

ここまで書いていて、自分の設定を確認したら sub がこうなっていました。

"repo:<オーナー>/<リポジトリ>:*"

末尾がワイルドカードです。そのリポジトリなら、どのブランチのどのワークフローからでも Assume できる状態でした。組織全体を許すよりはマシですが、適当なブランチを切ってワークフローを置けば通ってしまいます。

直しました。ワイルドカードをやめて、完全一致にします。

"StringEquals": {
  "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
  "token.actions.githubusercontent.com:sub": [
    "repo:<オーナー>/<リポジトリ>:ref:refs/heads/main",
    "repo:<オーナー>/<リポジトリ>:pull_request"
  ]
}

なぜ main だけでは足りないのか

最初は ref:refs/heads/main だけにしようとして、途中で気づきました。PR で動かしている CI が壊れます。

というのも、pull_request で実行されたときの subref:refs/heads/... になりません。ブランチ名ではなく、こうなります。

repo:<オーナー>/<リポジトリ>:pull_request

PR のたびに cdk diff を出して差分を確認しているので、ここを許可しないと CI が AccessDenied で落ちます。

この挙動は、絞ってみて初めて分かる部類のものです。どのトリガーでどんな sub になるかを確認してから条件を書く、というのが実務上の注意点になります。

絞ったあとの確認

条件を変えたら、意図どおりに許可・拒否されるかを確かめておきます。

ケース期待
main への push許可
main からの手動実行許可
PR での cdk diff許可
適当なブランチからの実行拒否
タグからの実行拒否
同一オーナーの別リポジトリ拒否
他人のリポジトリ拒否

「設定した」だけで通さず、拒否されるべきものが拒否されるかまで見ておくと安心です。

ロールの権限も絞る

信頼ポリシーで「誰が入れるか」を絞ったら、ロール自体の権限も最小にします。

今回の構成では、デプロイ用ロールに AWS リソースを直接触る権限を持たせていません。持たせているのは、CDK が作ったロールへ AssumeRole する権限だけです。

{
  "Effect": "Allow",
  "Action": "sts:AssumeRole",
  "Resource": "arn:aws:iam::<アカウントID>:role/cdk-*"
}

入口のロールを奪われても、そこから直接何かを壊せるわけではない、という形にしています。

あわせて、状態を持つスタック(DB や鍵)のデプロイは自動実行にせず、手動起動のワークフローに分けました。push のたびに走るのはアプリ側だけです。取り返しのつかない変更は、人が意図して叩いたときだけ動くようにしています。

注意しておく点

移してみて「ここは知っておかないと詰まる」と思った点をまとめます。OIDC そのものの欠点というより、運用でつまずく箇所です。

初期設定は長期キーより手間

アクセスキーなら5分ですが、OIDC はプロバイダとロールと信頼ポリシーが要ります。1回作れば以降は使い回せますが、最初の1回は確実に長期キーより時間がかかります。

sub の書式が変わることがある

GitHub は、2026年7月15日以降に作成されたリポジトリでは、所有者IDとリポジトリIDを含む形式を既定にするとしています。名前ベースで信頼ポリシーを書いていると、条件がマッチせず AccessDenied になる可能性があります。

リポジトリ名を変えたときも同じ問題が起きます。名前で縛っている以上、名前が変われば通らなくなります。

イベントの種類では絞りにくい

sub で区別できるのは主にブランチ・タグ・Environment・pull_request です。「push のときだけ」「特定のワークフローファイルからだけ」といった細かい制御は、sub だけでは表現しきれません。

セルフホストランナーだと読まれうる

GitHub のホステッドランナーではなく自前のマシンで走らせている場合、受け取ったトークンを環境変数経由で他のステップに渡していると、そのマシンを管理している人からトークンが見えます。OIDC にしたからといって、ランナー自体の安全性が保証されるわけではありません。

いちばん大きいリスクは、信頼ポリシーの外にある

これが本質的なところです。

信頼ポリシーをどれだけ厳密に書いても、リポジトリに書き込める人は、正規のワークフローに任意のコードを混ぜられます。攻撃する側から見れば、新しいブランチを作る必要も、条件をすり抜ける必要もありません。main のワークフローに1行足すだけで、正規の経路として実行されます。

つまり OIDC は「鍵の保管をなくす」ためのもので、「リポジトリへの書き込みを守る」ものではありません。ブランチ保護やレビュー必須の設定と組み合わせて、初めて意味を持ちます。

なお、Environment に承認者を設定して守るという手もありますが、プライベートリポジトリでこの保護ルールを使うには有料プランが必要です。無料の範囲では、ブランチ保護と sub の絞り込みで組み立てることになります。

移行しても、古い鍵は自動では消えない

最後に、移行のときに見落としやすい点を1つ。

OIDC に移しても、それまで使っていた IAM ユーザーとアクセスキーは残ったままです。 ワークフローから参照されなくなるだけで、キー自体は有効なので、放っておくと「誰も使っていないが有効な鍵」が残り続けます。

棚卸しは、アクセスキーの最終使用日で洗えます。長く使われていないものは、まず 無効化(Inactive) してから様子を見て、問題がなければ削除するのが安全です。無効化ならすぐ戻せるので、どこかで使われていた場合もエラーで気づけます。

まとめ

  • OIDC の本質は「保管する鍵をなくす」こと。実行のたびに短命の資格情報が発行される
  • 設定は3つ(プロバイダ・ロール・ワークフローの id-token: write
  • 信頼ポリシーの sub をどこまで絞るかが安全性そのもの。ワイルドカードは避ける
  • PR 実行時の subpull_request になる。ブランチ名では絞れない
  • 入口のロールには直接の権限を持たせず、必要なロールへの AssumeRole だけ渡す
  • OIDC は鍵の保管をなくすもので、リポジトリへの書き込みを守るものではない。ブランチ保護と組み合わせる
  • 移行後、古い長期キーは自分で消す

書きながら自分の設定を見直したら、ワイルドカードのまま運用していたことに気づきました。設定した当時は「動いた」で終わっていたのだと思います。仕組みを説明しようとすると、自分の穴が見えるものですね。

参照リンク