# Making GitHub Actions Keyless with OIDC: Throwing Away Long-Lived Keys with AWS IAM Roles and GCP Workload Identity Federation

> An implementation guide for abolishing long-lived cloud credentials from GitHub Actions CI/CD. Explained with real settings and Terraform: issuing short-lived tokens with OIDC federation, configuring AWS (IAM OIDC provider + role trust policy) and GCP (Workload Identity Federation), and narrowing the trust scope with sub/aud/repo/branch to achieve least privilege.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: CI/CD, セキュリティ, AWS, GCP, DevOps
- URL: https://tomodahinata.com/en/blog/github-actions-oidc-keyless-cicd-aws-gcp-guide
- Category: Infrastructure, IaC & CI/CD
- Pillar guide: https://tomodahinata.com/en/blog/aws-ecs-vs-eks-startup-decision-framework

## Key points

- The problem isn't that keys leak, but placing in CI an indefinite key whose damage doesn't stop even if it leaks. Replace it with OIDC federation's short-lived tokens
- The first step in a workflow is permissions: id-token: write. But this permission itself doesn't change the cloud; it only permits token issuance
- AWS, with an IAM OIDC provider + role trust policy, fixes aud with StringEquals and pins sub to one point — an environment or branch
- GCP, with Workload Identity Federation, must always set a match of assertion.repository in the Attribute Condition, turning away others' repositories at the door
- Wildcards in sub and an unset Attribute Condition are strictly forbidden. As defense-in-depth, layer environment approval, per-purpose role splitting, and SHA-pinning of Actions

---

"I want to deploy to AWS from CI/CD," "I want to push an image to GCP from GitHub Actions" — the requirement is one line. But the moment you settle the implementation by pasting `AWS_SECRET_ACCESS_KEY` or a service account key (JSON) into GitHub Secrets, your repository turns into **an attack surface holding a long-lived key that's "game over if it leaks."**

This article is an implementation guide for **completely abolishing long-lived cloud credentials** from GitHub Actions CI/CD. Instead of storing keys, you replace it with a configuration of "issue a short-lived signed ID token per workflow run via **OIDC federation** → the cloud side verifies 'which repo/branch/environment the run is from' and dispenses temporary credentials." **The key exists nowhere.**

As the subject matter, I'll weave in the configuration where I actually operate keyless CI/CD on **both AWS and GCP**. For an internal AI platform for a major domestic broadcaster ([keyless CI/CD realized with Workload Identity Federation](/case-studies/broadcaster-ai-content-platform)), I built the GCP side, and for another lumber-industry DX project, the AWS side, each with OIDC.

> **The rule of this article**: The trustworthy sources are only the **official documentation of GitHub / AWS / GCP (as of June 2026)**. Token claim names, action input names, and trust-policy keys are quoted from the official docs, and guesses are made explicit. Because cloud specs / recommendations get revised, always confirm the latest values officially before going to production. **A long-lived key is "game over if it leaks." Replacing it with OIDC's short-lived tokens is the current correct answer.**

---

## 0. Mental model: why a long-lived key is "the biggest attack surface"

Before getting into the design, get just this to sink in. The reason a long-lived key placed in CI is dangerous is not in abstraction but in **structure**.

### 0.1 What's wrong with a long-lived key

Storing `AWS_SECRET_ACCESS_KEY` or a GCP service account key (JSON) in GitHub Secrets — this is convenient, but it simultaneously carries the following properties.

- **Indefinite**: unless you explicitly rotate it, that key stays valid for many months. It's alive even when the person who issued it has forgotten about it.
- **Usable from anywhere**: a key doesn't distinguish "who used it, from where." From your laptop, from a malicious fork's PR, or from an attacker's server where it leaked — it can exercise production permissions all the same.
- **Leakage progresses quietly**: accidental output to logs, a malicious dependency package, a hijacked third-party Action, a misconfigured `pull_request_target` — there are countless paths for a secret to leak in CI. And even if it leaks, the key keeps working as-is, so **you can't notice**.

This is exactly why GitHub officially recommends OIDC. It states clearly: "you no longer need to duplicate your cloud credentials as long-lived GitHub secrets." **The problem is not "that keys leak," but "placing an indefinite key, whose damage doesn't stop even if it leaks, in the most-targeted place (CI)."**

### 0.2 What OIDC federation changes

OIDC (OpenID Connect) federation changes this structure fundamentally. The flow is this.

1. When a workflow run starts, **GitHub's OIDC provider** (`token.actions.githubusercontent.com`) issues a **signed JWT (ID token)** specific to that run.
2. This token has carved into it, as **claims**, "which repository, from which branch/tag/environment, and which workflow" is running.
3. The workflow presents this token to the cloud (AWS STS / GCP STS).
4. The cloud side matches it against a pre-configured **trust policy** and verifies whether "the `sub` claim and other claims are a match for the conditions preconfigured in the role's OIDC trust definition."
5. Only when they match does it dispense **short-lived temporary credentials that auto-expire after that one job.**

Borrowing GitHub's official wording, the OIDC token is exchanged for a "**short-lived access token that is only valid for a single job, and then automatically expires.**"

**The key doesn't exist.** The secret to store disappears, and on top of that the cloud side actively verifies "which run it came from." This is the core of keyless CI/CD.

### 0.3 Legacy vs. OIDC: decision table

| Aspect | Long-lived access key (legacy) | OIDC keyless (recommended) |
| --- | --- | --- |
| Stored secret | Yes (persisted in Secrets) | **None** (the token is issued at runtime) |
| Validity period | Indefinite (manual rotation) | **One job only, auto-expires** |
| Verification of the issuer | Can't (no principal info in the key) | **Can** (sub/repo/ref/environment claims) |
| Damage on leak | Production permissions keep being exercised until noticed | The token expires immediately, can't be reused |
| Rotation operation | Needed (a breeding ground for accidents if forgotten) | **Unneeded** (newly issued each time) |
| Narrowing the trust scope | Everyone holding the key | **Per repo/branch/environment** |
| Audit | Per key (who used it is unclear) | Traceable per claim |

"No rotation needed" looks plain but is enormous. The most common accident in long-lived-key operation is **the same key staying alive for years because rotation was forgotten.** OIDC erases that operational debt itself (YAGNI: don't hold a mechanism you don't need in the first place).

---

## 1. The starting point of everything: the `id-token: write` permission

For both AWS and GCP, the first step on the workflow side is common. **Permit GitHub to issue an OIDC token.** Make this explicit in the `permissions` block.

```yaml
permissions:
  id-token: write   # GitHub の OIDC プロバイダに JWT を発行させる（必須）
  contents: read    # actions/checkout がリポジトリを読むため
```

The official explanation is this. `id-token: write` "allows GitHub's OIDC provider to create a JSON Web Token for every run." But as a caution, **this permission itself has no power to change cloud resources.** It's strictly a permission of "you may create a token," and what can actually be done is decided by the cloud side's role/policy.

> **The starting point of least privilege**: `permissions` can be **declared per job too.** Attach `id-token: write` only to the deploy job that needs token issuance, and not to test-only jobs — this alone narrows the attack surface (SRP: split responsibility and permissions per job). It's robust to make the repo-wide default `permissions: {}` (deny-all) and add in the jobs that need it.

### 1.1 The shape of the `sub` claim — the lead actor that decides the trust scope

The most important claim carved into the OIDC token is `sub` (subject). The format GitHub officially shows changes by context.

- **Branch**: `repo:octo-org/octo-repo:ref:refs/heads/main`
- **environment**: `repo:octo-org/octo-repo:environment:prod`
- **Pull request**: `repo:OWNER/REPO:pull_request`
- **Tag**: `repo:OWNER/REPO:ref:refs/tags/v1.0.0`

The other main claims contained in the token (official) are as follows.

| Claim | Example | Meaning |
| --- | --- | --- |
| `iss` | `https://token.actions.githubusercontent.com` | Issuer (GitHub's OIDC provider) |
| `sub` | `repo:octo-org/octo-repo:ref:refs/heads/main` | **Who**: repo / branch / environment |
| `aud` | (fixed on the cloud side. For AWS, `sts.amazonaws.com`) | **For whom**: the token's recipient |
| `repository` | `octo-org/octo-repo` | Repository name |
| `ref` | `refs/heads/main` | Branch/tag reference |
| `environment` | `prod` | environment name |
| `job_workflow_ref` | Workflow file reference | Which workflow definition it ran from |

The cloud side's trust settings match against these claims. **Especially correctly narrowing the two — `sub` and `aud` — is almost everything about the safety of a keyless configuration.** From the next chapter, let me look concretely at AWS and GCP each.

---

## 2. AWS: IAM OIDC provider + role trust policy

Going keyless on the AWS side is established with **two settings**.

1. Create an **IAM OIDC identity provider** (register GitHub with AWS as the origin of trust).
2. Create an **IAM role**, and in its **trust policy** condition "OIDC tokens from which repo/branch/environment may `AssumeRole`."

### 2.1 The IAM OIDC provider

Register GitHub as a trusted ID provider of AWS. The settings the official docs show are as follows.

- **Provider URL**: `https://token.actions.githubusercontent.com`
- **Audience**: `sts.amazonaws.com` (when using the official action `aws-actions/configure-aws-credentials`)

AWS verifies that this audience matches the OIDC token's `aud` claim. It's a mechanism to confirm "is this token issued **for STS**."

### 2.2 The role trust policy — this is the heart

Just registering the OIDC provider, you still can't do anything. **Constraining "which GitHub run may become this role" with conditions** is the trust policy. The JSON GitHub officially shows is this.

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456123456: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:octo-org/octo-repo:*"
        }
      }
    }
  ]
}
```

Let me break down the meaning of each element.

- **`Action: sts:AssumeRoleWithWebIdentity`**: the dedicated action of assuming a role with a Web ID (= the OIDC token). It's a federation-only entrance that doesn't exist in the long-lived-key world.
- **`Principal.Federated`**: the ARN of the OIDC provider you registered earlier. The declaration "trust GitHub's OIDC."
- **`aud` condition (`StringEquals`)**: requires, by **strict match**, that the token is for `sts.amazonaws.com`. Omit this and room is born to divert a token of another purpose. **Always fix `aud` with `StringEquals`.**
- **`sub` condition**: here you narrow "which repo/branch/environment."

### 2.3 How far to narrow `sub` — the danger of wildcards

The official example above uses the **wildcard** `repo:octo-org/octo-repo:*`. This means "allow AssumeRole from **any** branch / PR / environment of this repository."

GitHub officially warns clearly on this point. Use a wildcard (`*`) in the `sub` condition and "**any branch, pull request merge branch, or environment**" can assume that role.

**This should be avoided in production.** For example, a possibility is born that this role (= production permissions) is grasped even from a PR sent by an external contributor. For a production-deploy role, **pin it to one point** with `StringEquals`.

```json
"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:ref:refs/heads/main"
  }
}
```

With this, only "**a run from the `main` branch of `octo-org/octo-repo`**" can become this role. To make it even harder, constrain by **environment**.

```json
"token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:environment:production"
```

The benefit of constraining by environment is that it combines with the **environment protection rules** explained in the next section. It becomes defense-in-depth where only "a run tied to the production environment" and "a run an approver approved" can assume the production role.

> **The principle of narrowing (ETC: be conscious of the unit at which change happens)**: make the trust condition correspond one-to-one with "when and from where do I want this permission used." Pin production deploy to `environment:production`, staging to `ref:refs/heads/develop`, and so on — **split roles per purpose.** Cram multiple purposes into one role with `StringLike` and the narrowing loosens, ending up little different from a wildcard.

### 2.4 The workflow side: `aws-actions/configure-aws-credentials`

Once the cloud side is prepared, the workflow becomes this.

```yaml
name: Deploy to AWS
on:
  push:
    branches: [main]

permissions:
  id-token: write   # OIDC トークン発行（必須）
  contents: read    # checkout 用

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # environment 縛り + 承認ゲートと連動
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          role-session-name: github-actions-deploy
          aws-region: ap-northeast-1

      # ここから先は一時クレデンシャルで AWS を操作（鍵は一切なし）
      - name: Push image to ECR
        run: |
          aws sts get-caller-identity   # 引き受けたロールの確認
          # docker build / push, ECS デプロイ など
```

The point is `role-to-assume`. **You just pass "the ARN of the role you want to assume," not a stored key.** The action internally obtains the OIDC token, calls `AssumeRoleWithWebIdentity`, and expands short-lived credentials into environment variables. Neither `AWS_ACCESS_KEY_ID` nor `AWS_SECRET_ACCESS_KEY` is written anywhere.

> **Pinning the Action (security)**: it's no coincidence that the official example fixes `configure-aws-credentials` with a **commit SHA** (`@e3dd...`). Because third-party Actions can be hijacked, **pinning with a full SHA** rather than a mutable tag like `@v4` is the basic defense against supply-chain attacks. Even if you erase keys with OIDC, having a malicious Action steal the temporary credentials puts the cart before the horse.

---

## 3. GCP: Workload Identity Federation (WIF)

Going keyless on the GCP side is done with **Workload Identity Federation (WIF)**. The GCP official definition is this. WIF is a mechanism that gives external workloads access to GCP resources "using **federated identities instead of a service account key**," and it "**eliminates the maintenance and security burden associated with service account keys.**"

A service account key (JSON) is a long-lived secret "game over if it leaks," the same as AWS's long-lived access key. WIF replaces this with a short-lived OAuth 2.0 token.

### 3.1 The cast: Pool and Provider

WIF has two components (official terms).

- **Workload Identity Pool**: a container that manages external IDs. The official docs recommend "**one pool per non-Google Cloud environment.**" The image is to create one for GitHub Actions.
- **Workload Identity Pool Provider**: something that **describes the relationship** between GCP and your ID provider (here, GitHub). You set GitHub's OIDC issuer here.

### 3.2 Attribute Mapping and Attribute Condition — narrowing the trust scope

These two decide WIF's safety. They correspond to AWS's `sub` condition.

**Attribute Mapping** converts GitHub's token claims to GCP attributes. Write it in CEL (Common Expression Language). Representative examples the official docs show:

- `google.subject=assertion.sub` (map GitHub's `sub` to GCP's principal ID)
- `attribute.repository=assertion.repository` (take in the repository name as a custom attribute)

**Attribute Condition** restricts "**which external ID may be allowed to authenticate**" in CEL. The setting the official docs strongly recommend is this.

```text
assertion.repository == 'OWNER/REPO'
```

The official docs clearly state the significance of this condition. This "**prevents credentials issued for other platforms from accessing GCP, and mitigates the confused deputy problem.**"

> **WIF's most important pitfall**: **not setting** the Attribute Condition, or setting it loosely, can make tokens issued from **any GitHub repository** (= others' repositories worldwide) accepted by your GCP provider. This is because GitHub's OIDC issuer is common to all GitHub users. **Always attach `assertion.repository == 'OWNER/REPO'` at provider creation** — this is exactly the same problem as not making AWS's `sub` a wildcard. Further adding `assertion.ref == 'refs/heads/main'` lets you constrain down to the branch.

### 3.3 Direct access vs. service account impersonation

WIF has two access models (official).

- **Direct resource access**: grant the external ID a role on the GCP resource **directly**. The simplest form, with "no intermediate service accounts or keys."
- **Service account impersonation**: the external ID accesses the resource **via an intermediate service account**. Used when you want to reuse existing service-account permissions, or when some GCP services presuppose impersonation.

The purpose of "**not creating a key**" is achieved by both. The difference is only whether you interpose an intermediate service account. New, direct access is simple (KISS); leveraging existing assets, impersonation — choose accordingly.

### 3.4 The workflow side: `google-github-actions/auth`

The workflow uses the official action `google-github-actions/auth`. The GCP official docs explain that this action "**exchanges a GitHub OIDC token for a Google Cloud access token.**"

```yaml
name: Deploy to GCP
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # OIDC トークン発行（必須）
    steps:
      - uses: actions/checkout@v4   # auth の前に必ず checkout

      - id: auth
        name: Authenticate to Google Cloud (WIF)
        uses: google-github-actions/auth@v3
        with:
          project_id: my-project
          # プロジェクト番号 + プール名 + プロバイダ名のフル識別子
          workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/my-repo
          service_account: deployer@my-project.iam.gserviceaccount.com  # インパーソネーション時のみ

      # 以降は短命トークンで GCP を操作（鍵JSONは一切なし）
      - name: Push to Artifact Registry / deploy Cloud Run
        run: gcloud run deploy ...
```

The meaning of the inputs (official):

- **`workload_identity_provider`**: "**the full identifier including the project number, pool name, and provider name.**" The format is `projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER`.
- **`service_account`**: the email of the service account to specify **only when impersonating**. For direct access (Direct WIF), **omit** it.
- **`create_credentials_file`**: default `true`. Generates a temporary auth file the subsequent `gcloud` / SDK references.
- **`token_format`**: `access_token` or `id_token`. There are cases where `id_token` is needed, like Cloud Run IAM invocation.

> **An important official note**: place `google-github-actions/auth` **after `actions/checkout`.** Reverse the order and you'll have an accident where the credentials file is overwritten/deleted by checkout.

---

## 4. AWS and GCP correspondence table: organize your head in one sheet

Going back and forth between the two clouds tends to confuse, so let me summarize **the correspondence of configuration elements** in one sheet. It's a map of how, to the same goal of "erase the key," you arrive in each cloud's vocabulary.

| Role | AWS | GCP (WIF) |
| --- | --- | --- |
| Register GitHub as the origin of trust | IAM **OIDC identity provider** | **Workload Identity Pool Provider** |
| GitHub's issuer | `token.actions.githubusercontent.com` | Same (GitHub's OIDC issuer) |
| Token recipient (aud) | `sts.amazonaws.com` | The provider setting's audience |
| Condition to narrow "from whom" | The trust policy's **`sub` condition** | **Attribute Condition** (`assertion.repository == '...'`) |
| Claim → internal attribute conversion | (referenced directly in the condition) | **Attribute Mapping** (`google.subject=assertion.sub`) |
| Target to grant actual permissions | **IAM role** | Direct role grant on the resource or **service account** |
| Dedicated token-exchange API | `sts:AssumeRoleWithWebIdentity` | GCP Security Token Service |
| Workflow-side action | `aws-actions/configure-aws-credentials` | `google-github-actions/auth` |
| Value passed on the workflow side | `role-to-assume` (role ARN) | `workload_identity_provider` (+`service_account`) |
| Common premise | `permissions: id-token: write` | `permissions: id-token: write` |

You can see **the structure is exactly the same.** "Register GitHub as trusted → narrow the issuer with claims → dispense short-lived credentials." Only the vocabulary differs; the design decisions are common to both clouds (DRY: don't do the same thinking twice). That's exactly why, once you understand one, you can build the other quickly.

---

## 5. Create the OIDC provider / WIF pool with Terraform

Clicking through the GUI has **zero reproducibility** and breeds misconfigurations (especially missed narrowing of `sub` / Attribute Condition). **The OIDC trust relationship is precisely what should be managed with IaC**, because who trusted what and when remains in code review and history.

### 5.1 AWS: OIDC provider + role

```hcl
# GitHub Actions を信頼する IAM OIDC プロバイダ
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"] # = aud
  # サムプリントは AWS が自動検証するため、近年は固定値ハードコード不要
  thumbprint_list = ["ffffffffffffffffffffffffffffffffffffffff"]
}

# main ブランチ + production environment からの実行だけを信頼するロール
data "aws_iam_policy_document" "trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    # aud は厳密一致で固定（流用防止）
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    # sub も厳密一致で1点固定（ワイルドカード禁止）
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:octo-org/octo-repo:environment:production"]
    }
  }
}

resource "aws_iam_role" "github_deploy" {
  name               = "github-actions-deploy"
  assume_role_policy = data.aws_iam_policy_document.trust.json
}

# 最小権限：このロールが触れてよいリソースだけを許可（例は雛形）
resource "aws_iam_role_policy" "deploy_least_privilege" {
  role = aws_iam_role.github_deploy.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["ecr:GetAuthorizationToken", "ecr:BatchGetImage", "ecr:PutImage"]
      Resource = "*" # 本番では特定 ECR リポジトリ ARN に絞る
    }]
  })
}
```

Note that the `condition` is written with `StringEquals`. Not `StringLike` using a wildcard, but **pinned to one point**. Because it remains in code, "why this was fixed here" can be discussed in review, and a change that loosens it always requires approval — this is the greatest advantage of handling trust relationships with IaC.

### 5.2 GCP: WIF pool + provider + attribute condition

```hcl
# GitHub Actions 用の Workload Identity Pool
resource "google_iam_workload_identity_pool" "github" {
  workload_identity_pool_id = "github"
  display_name              = "GitHub Actions"
}

# GitHub を OIDC プロバイダとして登録
resource "google_iam_workload_identity_pool_provider" "github" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "my-repo"

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }

  # Attribute Mapping：GitHub のクレームを GCP 属性へ
  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
    "attribute.ref"        = "assertion.ref"
  }

  # Attribute Condition：このリポジトリ以外のトークンを門前払い（必須）
  attribute_condition = "assertion.repository == 'octo-org/octo-repo'"
}
```

**Always set** `attribute_condition`. Without it, as mentioned, tokens of any GitHub repository worldwide can be accepted by the provider. Fix the issuing repository with `assertion.repository == 'octo-org/octo-repo'`, and if needed constrain down to the branch with `&& assertion.ref == 'refs/heads/main'`.

> **An HCL note**: the field names of `oidc`/`attribute_mapping` and the schema of `google_iam_workload_identity_pool_provider` can differ by Provider version. The above is a template showing the structure. Before applying to production, confirm the resource spec of your Google Provider version (verify).

---

## 6. Defense-in-depth: practices to harden the trust scope further

Don't be satisfied with just introducing OIDC. Going keyless is the "foundation," and it becomes robust only once you stack **operations that narrow the trust scope** on top of it. Let me list the moves that paid off in real projects.

### 6.1 Combine with environment protection rules

Constrain `sub` with `environment:production`, and set GitHub's **environment protection rules** (required reviewers, wait timer, deployable-branch restriction). Then what can assume the production role is —

1. a run tied to the `production` environment, and
2. only a run an approver approved,

a **double gate**. The OIDC `sub` verification (cloud side) and the environment approval (GitHub side) work as orthogonal defense layers.

### 6.2 Cut off abuse from PRs

The most common accident is the case where "**production permissions are grasped from a run from a fork or PR.**" The countermeasure is clear.

- **Pin the production role's `sub` to `ref:refs/heads/main` or `environment:production`**, and **reject from the start** tokens originating from `pull_request` (whose `sub` is `repo:OWNER/REPO:pull_request`).
- Because the wildcard `repo:OWNER/REPO:*` includes exactly this `pull_request`, **never use it for a production role.**
- Assign verification jobs a **read-only role** separate from production, and don't hand over write permissions.

GitHub's official warning that "a wildcard allows any branch, PR merge branch, or environment" has this PR abuse in mind.

### 6.3 Split roles by "purpose × environment"

Sharing one omnipotent role is the same mistake as sharing a long-lived key. **Split roles per purpose and environment, and give each role only least privilege.**

| Job | Trusted `sub` / condition | Granted permissions (minimal) |
| --- | --- | --- |
| Production deploy | `environment:production` | ECR push + only the relevant ECS service update |
| Staging deploy | `ref:refs/heads/develop` | Staging ECR / ECS only |
| Verification in a PR (read-only) | `pull_request` | Read-only (plan / lint only) |

This is the split I actually take on the AWS side. For Terraform, I separate **plan (static analysis with `tfsec` in a PR) / apply (a role with a permissions boundary) / drift detection (periodic cron → file an Issue with the diff)** in the workflow, and assign each a different OIDC role. The structure of **"you can never apply in a PR"** guarantees least privilege (SRP: plan and apply are separate responsibilities = separate roles).

### 6.4 Never omit aud

AWS's `aud` condition and GCP's provider audience guarantee **the token's destination.** Loosen this and room is born to divert a token issued for another purpose. Remember: `aud` is always **fixed with `StringEquals`** — no exceptions.

---

## 7. Real examples: operating keyless CI/CD on both clouds

To not end in abstraction, let me show two configurations I actually operate. **On both AWS and GCP, not a single long-lived key exists.**

### 7.1 The GCP side: a broadcaster's internal AI platform

For an internal AI platform for a major domestic broadcaster ([case study](/case-studies/broadcaster-ai-content-platform)), I made CI/CD **keyless with Workload Identity Federation (OIDC).**

- **Authenticate to GCP without issuing any service account key (JSON)** from GitHub Actions. The "game over if it leaks" key exists neither in the repository nor in CI.
- **Split out stg / prod with Cloud Build**, separating the deploy destination per environment.
- **Split DB migration into a dedicated job**, separating responsibility from the app deploy (SRP). A migration failure doesn't drag the whole deploy in.
- **Automate CodeQL and dependency updates** too, keeping supply-chain-side defense always running.
- **Infrastructure is 100% Terraform (about 71 modules)**. The WIF pool/provider/attribute condition are of course IaC-managed, so changes to the trust relationship always get onto review.

### 7.2 The AWS side: a lumber-industry-DX deploy pipeline

For another project (lumber-industry DX), I built the AWS side with OIDC.

- **Forced deploy ECR → ECS with GitHub Actions OIDC (no long-lived key)**. Don't place `AWS_SECRET_ACCESS_KEY` in Secrets — just pass the role ARN.
- **S3 sync + CloudFront invalidation** also via the OIDC role.
- Terraform separates **plan (`tfsec` in a PR) / apply (a role with a permissions boundary) / drift detection (periodic cron → file an Issue)**, assigning a different role to each stage.

What I can say through these two projects is that **going keyless with OIDC is a standard reproducible regardless of cloud.** AWS's `sub` condition and GCP's Attribute Condition differ in vocabulary, but what they do is the same — "**narrow the issuer and dispense short-lived tokens.**" Once you learn it, you can build it in either cloud in tens of minutes.

---

## 8. Summary: a keyless CI/CD cheat sheet

Finally, a quick-reference table for when you're unsure.

- **The grand principle**: don't place a long-lived key in CI. Erase both `AWS_SECRET_ACCESS_KEY` and the service account key JSON from Secrets.
- **The common first step**: `permissions: id-token: write` (+ `contents: read` for checkout) in the workflow. Minimize it per job.
- **AWS**: IAM OIDC provider (`token.actions.githubusercontent.com` / aud `sts.amazonaws.com`) → fix `aud` with `StringEquals` in the role trust policy, and **pin `sub` to one point** like `repo:OWNER/REPO:environment:production` → the workflow just passes `role-to-assume` to `aws-actions/configure-aws-credentials`.
- **GCP**: Workload Identity Pool + Provider → **always set** `assertion.repository == 'OWNER/REPO'` with the **Attribute Condition** → the workflow passes `workload_identity_provider` (+ `service_account` if needed) to `google-github-actions/auth`. Place it **after** `actions/checkout`.
- **Wildcards strictly forbidden**: both `*` in `sub` and an unset Attribute Condition invite in "any branch / PR / others' repositories." Always pin the production role to one point.
- **Defense-in-depth**: an approval gate with environment protection rules, role splitting by purpose × environment, a read-only role for PRs, and SHA-pinning of Actions.
- **Manage with IaC**: the OIDC provider / WIF pool / trust conditions in Terraform. Always get changes to the trust relationship onto review.

A long-lived key is a quiet time bomb that's "game over if it leaks." OIDC federation removes that bomb itself and replaces it with "**a short-lived token that expires after one use and whose issuer is verifiable.**" Going keyless is no longer an advanced luxury but **standard equipment for CI/CD.**

I operate **keyless CI/CD in production on both AWS and GCP** in a style of running development alone with generative AI (Claude Code) as a partner. I can handle, end to end, designing, implementing, and operating it — including IaC-izing the OIDC trust relationships with Terraform, least-privilege roles, and environment approval gates. **"My GitHub Actions still has long-lived keys pasted in…"** — that replacement is the most cost-effective security investment. Even from the requirements-organizing stage, feel free to consult me.

---

### Reference (official documentation)

- [About security hardening with OpenID Connect (GitHub Docs)](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — the concept of OIDC, `id-token: write`, the `sub` claim format, short-lived tokens
- [Configuring OpenID Connect in Amazon Web Services (GitHub Docs)](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services) — the IAM OIDC provider, the trust policy (`aud`/`sub`), `configure-aws-credentials`, the wildcard warning
- [Configuring OpenID Connect in Google Cloud Platform (GitHub Docs)](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-google-cloud-platform) — `google-github-actions/auth`, `workload_identity_provider`/`service_account`
- [Workload Identity Federation (Google Cloud Docs)](https://cloud.google.com/iam/docs/workload-identity-federation) — the concept of WIF, pool/provider, Attribute Mapping/Condition, key-free direct access
- [google-github-actions/auth (GitHub)](https://github.com/google-github-actions/auth) — Direct WIF / impersonation, each input (`workload_identity_provider` and others), checkout order
