Skip to main content
友田 陽大
Infrastructure, IaC & CI/CD
CI/CD
セキュリティ
AWS
GCP
DevOps

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
Reading time
22 min read
Author
友田 陽大
Share

"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), 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

AspectLong-lived access key (legacy)OIDC keyless (recommended)
Stored secretYes (persisted in Secrets)None (the token is issued at runtime)
Validity periodIndefinite (manual rotation)One job only, auto-expires
Verification of the issuerCan't (no principal info in the key)Can (sub/repo/ref/environment claims)
Damage on leakProduction permissions keep being exercised until noticedThe token expires immediately, can't be reused
Rotation operationNeeded (a breeding ground for accidents if forgotten)Unneeded (newly issued each time)
Narrowing the trust scopeEveryone holding the keyPer repo/branch/environment
AuditPer 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.

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.

ClaimExampleMeaning
isshttps://token.actions.githubusercontent.comIssuer (GitHub's OIDC provider)
subrepo:octo-org/octo-repo:ref:refs/heads/mainWho: repo / branch / environment
aud(fixed on the cloud side. For AWS, sts.amazonaws.com)For whom: the token's recipient
repositoryocto-org/octo-repoRepository name
refrefs/heads/mainBranch/tag reference
environmentprodenvironment name
job_workflow_refWorkflow file referenceWhich 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.

{
  "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.

"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.

"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.

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.

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."

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.

RoleAWSGCP (WIF)
Register GitHub as the origin of trustIAM OIDC identity providerWorkload Identity Pool Provider
GitHub's issuertoken.actions.githubusercontent.comSame (GitHub's OIDC issuer)
Token recipient (aud)sts.amazonaws.comThe provider setting's audience
Condition to narrow "from whom"The trust policy's sub conditionAttribute Condition (assertion.repository == '...')
Claim → internal attribute conversion(referenced directly in the condition)Attribute Mapping (google.subject=assertion.sub)
Target to grant actual permissionsIAM roleDirect role grant on the resource or service account
Dedicated token-exchange APIsts:AssumeRoleWithWebIdentityGCP Security Token Service
Workflow-side actionaws-actions/configure-aws-credentialsgoogle-github-actions/auth
Value passed on the workflow siderole-to-assume (role ARN)workload_identity_provider (+service_account)
Common premisepermissions: id-token: writepermissions: 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

# 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

# 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.

JobTrusted sub / conditionGranted permissions (minimal)
Production deployenvironment:productionECR push + only the relevant ECS service update
Staging deployref:refs/heads/developStaging ECR / ECS only
Verification in a PR (read-only)pull_requestRead-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), 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)

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading