GitHub ActionsからWorkload Identityで鍵なしデプロイすると、CDのterraform initが403で落ちる。原因は新しいリポジトリでOIDCのsubに数値IDが入ったこと。直し方と、plan用とapply用にSAを分けた判断、手元だけ通るSERVICE_DISABLEDも解説する。

本記事はプロモーションを含みます。
GitHub Actions から Google Cloud (GCP) へ、サービスアカウント (SA) の鍵を置かずに Workload Identity Federation でデプロイする構成を組んだ。手元で terraform apply までは通る。ところが develop へマージした後の CD で、terraform init が 403 (iam.serviceAccounts.getAccessToken) で落ちた。
結論から書くと、原因と直し方は次のとおり。
sub に owner と repo の数値 ID が入る。名前だけの sub で許可した SA は一致しなくなるsub の接頭辞を GitHub の API で引き、Terraform の変数に置いて、許可をそこから組み立てるattribute.repository) で許可した SA は PR の実行からも借りられる。plan 用 (読み取りだけ) と apply 用 (特定ブランチの push だけ) に SA を分けるSERVICE_DISABLED で落ちることがある。API の有効化は呼び出し元の quota project で判定されるためこの記事では、何を手掛かりに原因を確かめ、どう直し、何を選んだかを順に書く。
Workload Identity Federation では、GitHub Actions の job が GitHub から OIDC トークンを受け取り、GCP がその中身 (claim) を見て SA を借りてよいか決める。GCP のドキュメントは、少なくとも google.subject=assertion.sub をマッピングするよう勧めている。
SA を借りてよい相手は、IAM の member として次のどちらかの形で書く (Workload Identity Federation with deployment pipelines)。
| 書き方 | 対象 |
|---|---|
principal://iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/POOL/subject/SUBJECT | sub が SUBJECT と一致する実行だけ |
principalSet://iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/POOL/attribute.ATTRIBUTE_NAME/VALUE | 属性 (例: repository) が一致する実行すべて |
筆者の構成では、apply 用の SA を principal://.../subject/repo:OWNER/REPO:ref:refs/heads/develop で許可していた。develop への push の実行だけが apply できる、という形だ。
GitHub は 2026-04-23 に、OIDC トークンの既定の sub に変わらない数値 ID を入れる変更を告知している (GitHub Changelog)。
| 形式 | sub の例 |
|---|---|
| 旧 | repo:octocat/my-repo:ref:refs/heads/main |
| 新 (immutable subject) | repo:octocat@123456/my-repo@456789:ref:refs/heads/main |
対象は次のとおり。
理由は、名前は削除や改名の後に別の人が取り直せるからだ。同じ名前のリポジトリを作られると、名前だけの sub を信頼している側から SA を借りられてしまう。数値 ID はリポジトリごとに変わらないので、この乗っ取りを防げる。
筆者のリポジトリは 2026-10-02 に作ったもので、新しい形式の対象だった。リポジトリが新形式かどうかは API で確かめられる。
gh api repos/OWNER/REPO/actions/oidc/customization/sub
# => {"use_default":true,"use_immutable_subject":true,"sub_claim_prefix":"repo:OWNER@111/REPO@222"}
use_immutable_subject: true が返ったことで、原因が確定した。
変わったのは sub だけで、repository など名前の claim はそのまま残る。そのため、attribute.repository で許可していた plan 用の SA は今までどおり通った。sub の完全一致で許可していた apply 用の SA だけが 403 になった。
plan は通るのに CD の apply だけが落ちたのはこのためだ。
新しい形式の sub をそのまま許可に直書きする方法もある。筆者は、接頭辞 (repo:OWNER@111/REPO@222) を Terraform の変数に持ち、apply 用 SA の許可をそこから組み立てる形にした。新しい環境の手順書には、この値を gh api で引く手順を足した。
variable "github_oidc_sub_prefix" {
type = string # gh api repos/OWNER/REPO/actions/oidc/customization/sub の sub_claim_prefix
}
# apply 用 SA の許可。develop への push の実行だけ
member = "principal://iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/POOL/subject/${var.github_oidc_sub_prefix}:ref:refs/heads/develop"
こうしておくと、環境ごとに変わるのは変数の値 1 つだけになる。この修正を手元で apply してからマージすると、CD の terraform-apply は通った (No changes)。
既存のリポジトリでも、改名や移管をした時点で新しい形式に変わる。名前で組んだ sub の許可は、そのタイミングでエラーも警告も出さずに一致しなくなる。改名・移管をしたら、接頭辞を引き直して変数を更新する。
もう 1 つ、この構成を組んだときに直したのが SA の分け方だ。最初は GitHub から借りる SA を 1 本にしていた。レビュー (Claude と Codex) と自動のセキュリティ検査で、これを plan 用と apply 用の 2 本に分けた。
attribute.repository で許可すると、そのリポジトリのすべての workflow の実行が対象になる。どのブランチの push も、pull_request もだ。PR で terraform plan を流すために SA をリポジトリ単位で許可すると、PR の実行からも同じ SA の権限を借りられる。その SA に editor や IAM 管理の権限があれば、PR のブランチに書いた workflow から apply 相当の操作ができてしまう。
そこで次のように分けた。
| SA | 権限 | 許可する相手 |
|---|---|---|
| plan 用 | 読み取りだけ (viewer など) | principalSet://.../attribute.repository/OWNER/REPO |
| apply 用 | 変更できる権限 | principal://.../subject/<接頭辞>:ref:refs/heads/develop |
sub の後半は、実行のきっかけによって変わる (GitHub Docs: OpenID Connect reference)。
| 実行のきっかけ | sub (旧形式の例) |
|---|---|
| ブランチへの push | repo:OWNER/REPO:ref:refs/heads/BRANCH |
| PR | repo:OWNER/REPO:pull_request |
environment: を付けた job | repo:OWNER/REPO:environment:NAME |
apply 用 SA を ref:refs/heads/develop で許可すると、PR の実行 (pull_request) は一致しない。逆に、apply の job に environment: を付けると sub が environment:NAME の形に変わり、ref: で書いた許可に当たらなくなる。job に environment を足すときは、許可の書き方も合わせて変える。
Workload Identity pool の provider には、どのトークンを受け付けるかの条件 (attribute_condition) を書く。最初はここをリポジトリ名で絞っていた。
immutable subject の対応をレビュー (Claude) に出したところ、名前で絞ると、同じ名前で作り直されたリポジトリのトークンも入口を通る、と指摘された。plan 用 SA はリポジトリ名で許可しているので、入口を通ればそのまま借りられる。GCP のドキュメントも、repository などの名前の属性は乗っ取りを招きやすく、数値 ID の属性を使うよう勧めている。
入口の条件を、数値 ID を含む sub の接頭辞で絞る形に変えた。
attribute_condition = "assertion.sub.startsWith('${var.github_oidc_sub_prefix}:')"
GCP はこの式を受け付け、実際の GitHub Actions のトークンも通った。
PR の CI で初めて plan が走ったとき、今度は 403 SERVICE_DISABLED (Cloud Resource Manager API) で落ちた。手元では同じ plan が通っていた。
GCP は、API が有効かどうかを呼び出し元の quota project で判定する (Quota project overview)。ドキュメントには “The project checked for activation is the same project checked for rate quota” とある。
| 呼び出し元 | quota project |
|---|---|
| 手元のユーザ認証の ADC | gcloud auth application-default set-quota-project で指定したプロジェクト |
| サービスアカウント (impersonation を含む) | そのサービスアカウントのプロジェクト |
手元の ADC の quota project は、その API を有効にしてある別のプロジェクトだった。そのため手元では通り、CI の SA で呼んだときに初めて、SA のプロジェクトで API が無効なことが表に出た。
直し方は、CI の SA のプロジェクトで必要な API を有効にすることだ。Terraform なら google_project_service で管理できる。手元で同じ条件を再現したいときは、ADC の quota project を対象のプロジェクトに合わせてから plan する (Troubleshoot your ADC setup)。
手元の plan が通ることは、CI で API が有効なことの証拠にならない。初回の CI まで気付けないので、新しいプロジェクトでは最初に有効化する API を Terraform に書いておく。
sub が repo:OWNER@ID/REPO@ID:... になる。gh api .../actions/oidc/customization/sub で確かめるsub の接頭辞を変数に置き、principal://.../subject/... の許可をそこから組み立てるattribute.repository の許可は PR の実行も含む。書き込める SA はブランチを限った sub で許可し、plan 用とは分けるSERVICE_DISABLED になることがある。API は SA のプロジェクトで有効にするどれも、plan は通るのに CD の apply だけ落ちたり、手元だけ通ったりする形で表に出る。CI で動かすスクリプト側の落とし穴は 自動実行のbashが突然落ちる・失敗を見逃す4つの罠 にまとめている。
GitHub Actions からクラウドへの OIDC 連携を、仕組みから押さえておくなら『GitHub CI/CD実践ガイド』が役に立つ。OpenID Connect によるクラウド連携に 1 章を割き (例は AWS)、セキュリティの章で OIDC のハードニングも扱っている。
GitHub CI/CD実践ガイド (野村友規 著、技術評論社)