GitHub Actions: OIDC 免密鑰部署到 AWS / GCP / Azure

用 OIDC 取代長期 cloud credentials,實作 AWS / GCP / Azure 的免密鑰部署,涵蓋信任策略與安全收斂

GitHub Actions: OIDC 免密鑰部署到 AWS / GCP / Azure

標籤:#DevOps #GitHub Actions #OIDC #雲端部署 #安全

不再儲存長期 AWS Access Key / GCP Service Account JSON / Azure 密碼,改用 OIDC 換短期 token 進行雲端部署


目錄


為什麼要用 OIDC?

傳統做法的問題

# ❌ 傳統:長期 access key 存在 GitHub Secrets
- uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: ap-northeast-1

痛點:

  1. 長期憑證一旦外洩:rotation 是惡夢,所有 repo 都要改
  2. 權限太大:通常會給 admin 或非常寬的權限,難以最小化
  3. 稽核困難:CloudTrail 看到的是同一個 user / role 反覆呼叫,看不出來「是哪個 repo / workflow 在做事」
  4. 離職員工的金鑰:很容易遺漏不停用
  5. GitHub Secrets 本身的 trust boundary:任何 repo admin 都看不到,但能在 workflow 內使用 → branch protection 沒設好就有風險

OIDC 的優勢

比較項目 長期 Access Key OIDC
憑證有效期 永久(直到 rotate) 1 小時
儲存位置 GitHub Secrets 不需要儲存
稽核可追蹤性 中(看到 IAM user) 高(JWT 包含 repo / branch / actor)
權限收斂 困難 可以針對 repo / branch 設權限
金鑰 rotation 手動 自動(每次 workflow 都拿新的)

OIDC 運作原理

整體流程

┌─────────────────┐                    ┌──────────────────┐
│ GitHub Actions  │                    │  Cloud Provider   │
│    Runner       │                    │  (AWS/GCP/Azure)  │
└────────┬────────┘                    └─────────┬────────┘
         │                                       │
         │  1. 跟 GitHub OIDC Provider 要 JWT     │
         │  ────────────────────────────────────► │
         │                                       │
         │  2. 拿到簽名過的 JWT(含 repo/branch)   │
         │                                       │
         │  3. 把 JWT 送到 Cloud Provider         │
         │  ────────────────────────────────────► │
         │                                       │
         │                            4. Cloud 驗證:
         │                               - 簽名是否來自 GitHub
         │                               - sub claim 符合信任策略
         │                               - aud claim 正確
         │                                       │
         │  5. 回傳短期 credentials               │
         │  ◄────────────────────────────────── │
         │                                       │
         │  6. 用短期 credentials 呼叫 API         │
         │  ────────────────────────────────────► │

三個關鍵組件

  1. GitHub OIDC Provider(token.actions.githubusercontent.com)

    • 每個 GHA workflow 都能跟它要 JWT
    • JWT 內含 repo、branch、ref、actor 等 claims
  2. Cloud Provider 的信任設定

    • 在 AWS/GCP/Azure 設定「信任 GitHub OIDC Provider」
    • 指定哪些 JWT 可以換成哪個 IAM Role / Service Account
  3. GHA 內的權限宣告

    permissions:
      id-token: write  # 允許跟 OIDC Provider 要 JWT
      contents: read
    

GHA OIDC Token 結構

實際 JWT decode 出來會像這樣:

{
  "iss": "https://token.actions.githubusercontent.com",
  "sub": "repo:my-org/my-repo:ref:refs/heads/main",
  "aud": "https://github.com/my-org",
  "ref": "refs/heads/main",
  "sha": "abc123...",
  "repository": "my-org/my-repo",
  "repository_owner": "my-org",
  "actor": "alice",
  "workflow": "Deploy",
  "head_ref": "",
  "base_ref": "",
  "event_name": "push",
  "job_workflow_ref": "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main",
  "iat": 1700000000,
  "exp": 1700003600
}

最常被用來做信任判斷的 claims

Claim 用途
sub 主要識別字串,可組合 repo + branch + environment + workflow
repository repo 全名(owner/name)
repository_owner organization 或使用者名
ref git ref(refs/heads/mainrefs/tags/v1)
environment 如果 job 指定了 environment,會出現
job_workflow_ref 哪個 workflow 檔案觸發的

sub 的格式

sub 是字串,GitHub 根據 job 屬性組合而成:

觸發情境 sub 格式
Push 到 branch repo:OWNER/REPO:ref:refs/heads/BRANCH
PR repo:OWNER/REPO:pull_request
Tag repo:OWNER/REPO:ref:refs/tags/TAG
Environment repo:OWNER/REPO:environment:ENV
Workflow 從 reusable workflow 呼叫 repo:OWNER/REPO:job_workflow_ref:OTHER_REPO/.github/workflows/X.yml@refs/heads/main

信任策略可以用萬用字元(*)和 wildcard 比對,但寫太寬會出大事(下面 sub claim 設計策略章節詳述)


AWS 部署實作

Step 1:建立 IAM OIDC Provider

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

AWS 現在已經自動驗證 thumbprint,寫一個假的也能跑,但建議放正確的(來自 GitHub 官方文件)

Step 2:建立 IAM Role 與信任策略

trust-policy.json:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
      },
      "StringLike": {
        "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
      }
    }
  }]
}
aws iam create-role \
  --role-name github-actions-deploy \
  --assume-role-policy-document file://trust-policy.json

aws iam attach-role-policy \
  --role-name github-actions-deploy \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess

Step 3:在 GHA 中使用

name: Deploy to AWS

on:
  push:
    branches: [main]

permissions:
  id-token: write   # 必要!允許 GHA 拿 JWT
  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
          role-session-name: gha-${{ github.run_id }}

      - name: Deploy
        run: |
          aws s3 sync ./dist s3://my-bucket/

Step 4:更嚴格的信任策略(多 branch / environment)

允許 main + tags + production environment:

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": [
        "repo:my-org/my-repo:ref:refs/heads/main",
        "repo:my-org/my-repo:ref:refs/tags/v*",
        "repo:my-org/my-repo:environment:production"
      ]
    }
  }
}

注意:StringEquals 不支援 wildcard,要用 StringLike 才能用 *


GCP 部署實作

GCP 的設計用 Workload Identity Federation(WIF),透過 Pool + Provider 對應到 Service Account。

Step 1:建立 Workload Identity Pool

gcloud iam workload-identity-pools create "github-pool" \
  --project="my-gcp-project" \
  --location="global" \
  --display-name="GitHub Actions Pool"

Step 2:建立 OIDC Provider

gcloud iam workload-identity-pools providers create-oidc "github-provider" \
  --project="my-gcp-project" \
  --location="global" \
  --workload-identity-pool="github-pool" \
  --display-name="GitHub" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
  --attribute-condition="assertion.repository_owner == 'my-org'" \
  --issuer-uri="https://token.actions.githubusercontent.com"

關鍵點:

  • attribute-mapping:把 GitHub JWT 的 claim 映射到 GCP 屬性
  • attribute-condition:強烈建議加上,否則任何 GitHub repo 都可以用這個 provider!

Step 3:建立 Service Account 並授權

gcloud iam service-accounts create gha-deploy \
  --project="my-gcp-project"

# 讓 GHA 可以 impersonate 這個 SA
gcloud iam service-accounts add-iam-policy-binding \
  "gha-deploy@my-gcp-project.iam.gserviceaccount.com" \
  --project="my-gcp-project" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo"

# 給 SA 實際操作權限
gcloud projects add-iam-policy-binding my-gcp-project \
  --member="serviceAccount:gha-deploy@my-gcp-project.iam.gserviceaccount.com" \
  --role="roles/storage.admin"

Step 4:在 GHA 中使用

name: Deploy to GCP

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

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

      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider
          service_account: gha-deploy@my-gcp-project.iam.gserviceaccount.com

      - uses: google-github-actions/setup-gcloud@v2

      - run: |
          gsutil rsync -r ./dist gs://my-bucket/

GCP attribute-condition 範例

限定只能從特定 organization:

assertion.repository_owner == 'my-org'

限定特定 repo 與 branch:

assertion.repository == 'my-org/my-repo' && assertion.ref == 'refs/heads/main'

進階:特定 environment:

assertion.repository == 'my-org/my-repo' && assertion.environment == 'production'

attribute-condition 用的是 CEL 表達式語言,可以做複雜邏輯


Azure 部署實作

Azure 用 Federated Identity Credentials 與 App Registration 對應。

Step 1:建立 App Registration

az ad app create --display-name "github-actions-deploy"
# 拿到 appId

az ad sp create --id <appId>
# 拿到 servicePrincipalObjectId

Step 2:建立 Federated Credential

fed-cred.json:

{
  "name": "gha-main-branch",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:my-org/my-repo:ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"]
}
az ad app federated-credential create \
  --id <appId> \
  --parameters fed-cred.json

每種觸發情境要建一個 federated credential(branch / tag / environment 各一筆):

// production environment
{
  "name": "gha-production-env",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:my-org/my-repo:environment:production",
  "audiences": ["api://AzureADTokenExchange"]
}

Step 3:授權給 App Registration

# Subscription 層級的 Contributor
az role assignment create \
  --assignee <appId> \
  --role Contributor \
  --scope /subscriptions/<subscription-id>/resourceGroups/my-rg

Step 4:在 GHA 中使用

name: Deploy to Azure

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

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

      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - run: |
          az group list
          az webapp deploy --src-path ./app.zip --name my-webapp -g my-rg

vars.* 是 GitHub Actions Variables(非 secret),適合放 client-id 等公開資訊


sub claim 設計策略

sub 設計決定了誰可以 assume role,寫錯的後果嚴重。

危險的寫法

1. 過寬 — 任何 repo 都能用

"token.actions.githubusercontent.com:sub": "repo:*"

❌ 災難:任何 GitHub repo 都可以 assume 你的 role


2. 沒鎖定 owner

"token.actions.githubusercontent.com:sub": "repo:*/my-repo:*"

❌ 災難:有人在自己的帳號建 my-repo 就能 assume


3. 沒檢查 ref / environment

"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*"

⚠️ 風險:任何 branch 或 PR(包括外部 fork PR)都能 assume

推薦的寫法

Production 部署 — 只允許 environment

{
  "StringLike": {
    "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:environment:production"
  }
}

搭配 GitHub Environment 的 protection rules(reviewer approval、deploy 限制 branch),確保只有特定流程能觸發。


多種觸發 — 用陣列

{
  "StringLike": {
    "token.actions.githubusercontent.com:sub": [
      "repo:my-org/my-repo:ref:refs/heads/main",
      "repo:my-org/my-repo:ref:refs/tags/v*",
      "repo:my-org/my-repo:environment:staging"
    ]
  }
}

Reusable Workflow 場景 — 用 job_workflow_ref

如果你的 reusable workflow 集中放在 my-org/ci-templates,然後讓所有 repo 呼叫它,信任策略應該寫:

{
  "StringLike": {
    "token.actions.githubusercontent.com:job_workflow_ref": "my-org/ci-templates/.github/workflows/deploy.yml@refs/tags/v*"
  }
}

這樣只信任「來自指定 reusable workflow + 指定 tag」,即使 caller repo 被攻破,攻擊者也無法直接 assume role。

AWS 要 IAM Provider 在 additional audience 加上 token.actions.githubusercontent.com:job_workflow_ref

設計決策表

需求 推薦 sub
只在 main branch 部署 repo:OWNER/REPO:ref:refs/heads/main
只在 release tag 部署 repo:OWNER/REPO:ref:refs/tags/v*
用 GitHub Environment 保護 repo:OWNER/REPO:environment:ENV
信任 reusable workflow job_workflow_ref 條件
整個 org 都可以(慎用) repository_owner == 'my-org' 且加其他條件

常見錯誤排查

錯誤 1:Not authorized to perform sts:AssumeRoleWithWebIdentity

可能原因:

  1. permissions: id-token: write 沒設
  2. trust policy 的 sub 對不上 — 印出實際的 sub 看看
  3. OIDC Provider 沒建 — 確認 AWS IAM Provider 存在
  4. aud 不對 — AWS 預設要 sts.amazonaws.com

Debug 技巧:在 workflow 內印出 JWT(用於 debug,正式不要做)

- name: Debug OIDC token
  run: |
    IDTOKEN=$(curl -sLS -H "User-Agent: actions/oidc-client" -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com")
    echo "$IDTOKEN" | jq -R 'split(".") | .[1] | @base64d | fromjson'
  env:
    ACTIONS_ID_TOKEN_REQUEST_TOKEN: ${{ env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }}
    ACTIONS_ID_TOKEN_REQUEST_URL: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }}

錯誤 2:GCP Permission denied on resource

步驟:

  1. 檢查 workload identity provider 的 attribute-condition — 太嚴會拒絕,太鬆會放行錯誤的請求
  2. 檢查 SA 是否有 roles/iam.workloadIdentityUser:
    gcloud iam service-accounts get-iam-policy SA_EMAIL
    
  3. 檢查 SA 是否有實際操作權限:roles/storage.admin
  4. member 路徑:principalSet://... 還是 principal://...(差別在於是否 specific subject)

錯誤 3:Azure AADSTS70021

訊息:No matching federated identity record found

原因:subject 不匹配,例如 federated credential 設了 environment:production,但 workflow 沒寫 environment: production

檢查:

az ad app federated-credential list --id <appId>

對照 workflow 內的 environment: 或 ref。


最佳實踐

1. 每個用途一個 role,不要共用

github-actions-deploy-staging   ← S3、ECS
github-actions-deploy-prod      ← S3、ECS、嚴格 sub
github-actions-publish-ecr      ← 只能 push ECR

理由:OIDC 的優勢就是「能精細劃分」,共用就失去意義。


2. 用 GitHub Environment 做最後一道牆

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # 觸發 environment protection
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::...:role/prod-deploy

Environment 可以設:

  • Required reviewers:需要 approval 才能 deploy
  • Wait timer:延遲 N 分鐘才能執行
  • Deployment branches:只允許特定 branch
  • Environment secrets / vars:這個 env 專屬的 secret

3. 不要把 cloud account ID 寫死在 workflow

# ❌
- with:
    role-to-assume: arn:aws:iam::123456789012:role/deploy

# ✅ 用 vars(不需要保密,但可以集中管理)
- with:
    role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/deploy

4. 啟用 CloudTrail / Cloud Audit Logs

OIDC assume role 的呼叫會出現在 CloudTrail / Cloud Logs,可以據此設定告警:

  • 異常 IP assume role
  • 從非預期 repo 嘗試 assume
  • 短時間多次失敗

5. Token 拿到後馬上用,不要傳遞

每次 workflow run 拿到的 token 是該 job 專屬的,不要透過 artifact / output 傳給另一個 job。

# ❌ 把 credentials 寫入檔案再上傳
- run: aws configure ... && tar czf creds.tgz ~/.aws
- uses: actions/upload-artifact@v4
  with: { path: creds.tgz }

# ✅ 每個需要的 job 自己重新 assume
jobs:
  deploy-s3:
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
  deploy-ecs:
    steps:
      - uses: aws-actions/configure-aws-credentials@v4  # 重新 assume

常見問題

Q1:permissions: id-token: write 跟一般 permissions 有什麼差?

id-token: write 是「允許這個 job 跟 GitHub OIDC Provider 要 JWT」的權限,和雲端權限無關。沒設這個 GHA 連 JWT 都拿不到,更別說 assume role。


Q2:能不能不用 GitHub Environment,直接寫死信任 branch?

可以,但失去 environment 的 protection。對於 production 強烈建議用 environment 多一層 reviewer。


Q3:如果攻擊者拿到我的 repo write 權限,OIDC 還安全嗎?

不安全。攻擊者可以改 workflow 把短期 credentials 撈出來。OIDC 的優勢是「沒有長期 credentials 可洩漏」,但 repo 寫權限本身就是攻擊面。

緩解:

  • 用 protected branch + required reviews
  • 用 Environment 的 required reviewer
  • job_workflow_ref 信任 reusable workflow,讓敏感操作集中在 ci-templates repo

Q4:fork PR 會不會自動拿到 OIDC token?

外部 fork PR 不會拿到完整的 secret 與 OIDC token,GitHub 預設會阻擋。除非 maintainer 在 PR 上 approval 才會允許執行有 secret / OIDC 的 workflow。


Q5:可以同一個 workflow 部署到多個 cloud 嗎?

可以,只是要分別呼叫各自的 configure action:

- uses: aws-actions/configure-aws-credentials@v4
  with: { role-to-assume: ... }
- uses: azure/login@v2
  with: { client-id: ..., tenant-id: ... }
- uses: google-github-actions/auth@v2
  with: { workload_identity_provider: ... }

每次都要 id-token: write 但 GHA 內部會分別請求對應 audience 的 JWT。


Q6:OIDC token 過期了會怎樣?

預設有效期 1 小時。長時間執行的 job(例如大型 build)如果超過 1 小時,要重新呼叫 configure action 換新的 credentials。某些 SDK 會自動 refresh(透過 STS / metadata),但是不要假設。


Q7:aud 一定要是 sts.amazonaws.com 嗎?

不一定。預設 AWS 用 sts.amazonaws.com,但你可以自訂(透過 audience 參數):

- uses: aws-actions/configure-aws-credentials@v4
  with:
    audience: my-custom-audience
    role-to-assume: ...

對應 trust policy 要改:

"StringEquals": {
  "token.actions.githubusercontent.com:aud": "my-custom-audience"
}

這在多 cloud / 多 tenant 環境可以隔離不同信任域。


總結

核心要點

  • OIDC 換短期 token,取代長期 cloud credentials
  • 三大雲都支援,但設計不同:AWS 用 IAM Role + trust policy,GCP 用 Workload Identity Federation,Azure 用 Federated Credentials
  • permissions: id-token: write 必填,沒設拿不到 JWT
  • sub claim 設計是安全的關鍵,過寬等於沒設
  • 搭配 GitHub Environment 做 reviewer / branch protection,多一層防護
  • 不要傳遞 credentials 給其他 job,每個 job 自己 assume

三大雲快速對照

步驟 AWS GCP Azure
1. 建 OIDC Provider IAM OIDC Provider Workload Identity Pool + Provider Federated Credential
2. 信任策略寫法 Trust policy JSON attribute-condition CEL Federated Credential's subject
3. 對應的身份 IAM Role Service Account App Registration
4. GHA 用的 Action aws-actions/configure-aws-credentials google-github-actions/auth azure/login
5. 預設 audience sts.amazonaws.com 自動 api://AzureADTokenExchange

速查 GHA 設定

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # 推薦
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/deploy
          aws-region: ${{ vars.AWS_REGION }}
      - run: aws s3 sync ./dist s3://${{ vars.S3_BUCKET }}/

建立日期:2026-05-25

🔗相關文章