GitHub ActionsのOIDCでGCPが403になる原因

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

GitHub ActionsのOIDCでGCPが403になる原因

本記事はプロモーションを含みます。

はじめに:手元の terraform は通るのに、CD だけ 403 で落ちる

手元のユーザ認証では terraform が通るのに、GitHub Actions の CD では terraform init が 403 で落ちる

GitHub Actions から Google Cloud (GCP) へ、サービスアカウント (SA) の鍵を置かずに Workload Identity Federation でデプロイする構成を組んだ。手元で terraform apply までは通る。ところが develop へマージした後の CD で、terraform init が 403 (iam.serviceAccounts.getAccessToken) で落ちた。

結論から書くと、原因と直し方は次のとおり。

  • 2026-07-15 より後に作ったリポジトリでは、GitHub の OIDC トークンの sub に owner と repo の数値 ID が入る。名前だけの sub で許可した SA は一致しなくなる
  • sub の接頭辞を GitHub の API で引き、Terraform の変数に置いて、許可をそこから組み立てる
  • リポジトリ名 (attribute.repository) で許可した SA は PR の実行からも借りられる。plan 用 (読み取りだけ) と apply 用 (特定ブランチの push だけ) に SA を分ける
  • 手元で通っても CI では SERVICE_DISABLED で落ちることがある。API の有効化は呼び出し元の quota project で判定されるため

この記事では、何を手掛かりに原因を確かめ、どう直し、何を選んだかを順に書く。

鍵なしデプロイの仕組み:sub が一致した実行だけが SA を借りられる

GitHub Actions が OIDC トークンを発行し、GCP が sub を許可と照らして、一致したときだけ SA のトークンを渡す

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/SUBJECTsub が 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 できる、という形だ。

原因:新しいリポジトリでは sub に数値 ID が入る

旧形式の sub は名前だけだが、2026-07-15 より後に作ったリポジトリでは owner と repo の後ろに @ と数値 ID が付く

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

対象は次のとおり。

  • 2026-07-15 より後に作ったリポジトリ
  • 2026-07-15 より後に改名・移管したリポジトリ
  • 既存のリポジトリは、organization かリポジトリの設定で opt-in したときだけ

理由は、名前は削除や改名の後に別の人が取り直せるからだ。同じ名前のリポジトリを作られると、名前だけの 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 が返ったことで、原因が確定した。

plan は通り、apply だけ落ちた理由

repository 属性はリポジトリ名のままなので plan 用 SA は通り、sub の完全一致で許可した apply 用 SA だけが 403 になった

変わったのは sub だけで、repository など名前の claim はそのまま残る。そのため、attribute.repository で許可していた plan 用の SA は今までどおり通った。sub の完全一致で許可していた apply 用の SA だけが 403 になった。

plan は通るのに CD の apply だけが落ちたのはこのためだ。

直し方:sub の接頭辞を変数に置いて組み立てる

gh api で引いた sub の接頭辞を Terraform の変数に置き、apply 用 SA の許可をそこから組み立てる

新しい形式の 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 の許可は、そのタイミングでエラーも警告も出さずに一致しなくなる。改名・移管をしたら、接頭辞を引き直して変数を更新する。

PR の実行からも SA を借りられる:plan 用と apply 用に分けた

SA が 1 本だと PR の実行からも apply の権限を借りられる。plan 用 (読み取りだけ) と apply 用 (develop の push だけ) に分けた

もう 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 の形が違う

push は ref:refs/heads/ブランチ、PR は pull_request、environment 付きの job は environment:名前 で sub が終わる

sub の後半は、実行のきっかけによって変わる (GitHub Docs: OpenID Connect reference)。

実行のきっかけsub (旧形式の例)
ブランチへの pushrepo:OWNER/REPO:ref:refs/heads/BRANCH
PRrepo:OWNER/REPO:pull_request
environment: を付けた jobrepo:OWNER/REPO:environment:NAME

apply 用 SA を ref:refs/heads/develop で許可すると、PR の実行 (pull_request) は一致しない。逆に、apply の job に environment: を付けると sub が environment:NAME の形に変わり、ref: で書いた許可に当たらなくなる。job に environment を足すときは、許可の書き方も合わせて変える。

pool の入口もリポジトリ名でなく接頭辞で絞る

pool の入口の条件をリポジトリ名の一致から sub の接頭辞の一致に変え、同じ名前で作り直されたリポジトリを入れない

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 のトークンも通った。

手元で通る plan が CI では SERVICE_DISABLED で落ちる

手元のユーザ ADC は別のプロジェクトを quota project にしていたので通り、CI の SA は自分のプロジェクトで判定されて SERVICE_DISABLED になった

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
手元のユーザ認証の ADCgcloud 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 に書いておく。

まとめ

鍵なしデプロイで 403 が出たら、sub の形式、SA の許可の範囲、pool の入口、quota project の 4 つを確かめる
  • 2026-07-15 より後に作った (または改名・移管した) リポジトリでは、OIDC の sub が repo:OWNER@ID/REPO@ID:... になる。gh api .../actions/oidc/customization/sub で確かめる
  • sub の接頭辞を変数に置き、principal://.../subject/... の許可をそこから組み立てる
  • attribute.repository の許可は PR の実行も含む。書き込める SA はブランチを限った sub で許可し、plan 用とは分ける
  • pool の入口は、名前でなく数値 ID を含む接頭辞で絞る
  • 手元で通っても CI で SERVICE_DISABLED になることがある。API は SA のプロジェクトで有効にする

どれも、plan は通るのに CD の apply だけ落ちたり、手元だけ通ったりする形で表に出る。CI で動かすスクリプト側の落とし穴は 自動実行のbashが突然落ちる・失敗を見逃す4つの罠 にまとめている。

GitHub Actions からクラウドへの OIDC 連携を、仕組みから押さえておくなら『GitHub CI/CD実践ガイド』が役に立つ。OpenID Connect によるクラウド連携に 1 章を割き (例は AWS)、セキュリティの章で OIDC のハードニングも扱っている。

GitHub CI/CD実践ガイド (野村友規 著、技術評論社) 画像: 楽天市場

GitHub CI/CD実践ガイド (野村友規 著、技術評論社)

参考