"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.
- When a workflow run starts, GitHub's OIDC provider (
token.actions.githubusercontent.com) issues a signed JWT (ID token) specific to that run. - This token has carved into it, as claims, "which repository, from which branch/tag/environment, and which workflow" is running.
- The workflow presents this token to the cloud (AWS STS / GCP STS).
- The cloud side matches it against a pre-configured trust policy and verifies whether "the
subclaim and other claims are a match for the conditions preconfigured in the role's OIDC trust definition." - 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.
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:
permissionscan be declared per job too. Attachid-token: writeonly 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 defaultpermissions: {}(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.
- Create an IAM OIDC identity provider (register GitHub with AWS as the origin of trust).
- 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 actionaws-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."audcondition (StringEquals): requires, by strict match, that the token is forsts.amazonaws.com. Omit this and room is born to divert a token of another purpose. Always fixaudwithStringEquals.subcondition: 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 toref:refs/heads/develop, and so on — split roles per purpose. Cram multiple purposes into one role withStringLikeand 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-credentialswith a commit SHA (@e3dd...). Because third-party Actions can be hijacked, pinning with a full SHA rather than a mutable tag like@v4is 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'ssubto 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'ssuba wildcard. Further addingassertion.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 isprojects/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: defaulttrue. Generates a temporary auth file the subsequentgcloud/ SDK references.token_format:access_tokenorid_token. There are cases whereid_tokenis needed, like Cloud Run IAM invocation.
An important official note: place
google-github-actions/authafteractions/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
# 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_mappingand the schema ofgoogle_iam_workload_identity_pool_providercan 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 —
- a run tied to the
productionenvironment, and - 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
subtoref:refs/heads/mainorenvironment:production, and reject from the start tokens originating frompull_request(whosesubisrepo:OWNER/REPO:pull_request). - Because the wildcard
repo:OWNER/REPO:*includes exactly thispull_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), 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_KEYin Secrets — just pass the role ARN. - S3 sync + CloudFront invalidation also via the OIDC role.
- Terraform separates plan (
tfsecin 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_KEYand the service account key JSON from Secrets. - The common first step:
permissions: id-token: write(+contents: readfor checkout) in the workflow. Minimize it per job. - AWS: IAM OIDC provider (
token.actions.githubusercontent.com/ audsts.amazonaws.com) → fixaudwithStringEqualsin the role trust policy, and pinsubto one point likerepo:OWNER/REPO:environment:production→ the workflow just passesrole-to-assumetoaws-actions/configure-aws-credentials. - GCP: Workload Identity Pool + Provider → always set
assertion.repository == 'OWNER/REPO'with the Attribute Condition → the workflow passesworkload_identity_provider(+service_accountif needed) togoogle-github-actions/auth. Place it afteractions/checkout. - Wildcards strictly forbidden: both
*insuband 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) — the concept of OIDC,
id-token: write, thesubclaim format, short-lived tokens - Configuring OpenID Connect in Amazon Web Services (GitHub Docs) — the IAM OIDC provider, the trust policy (
aud/sub),configure-aws-credentials, the wildcard warning - Configuring OpenID Connect in Google Cloud Platform (GitHub Docs) —
google-github-actions/auth,workload_identity_provider/service_account - Workload Identity Federation (Google Cloud Docs) — the concept of WIF, pool/provider, Attribute Mapping/Condition, key-free direct access
- google-github-actions/auth (GitHub) — Direct WIF / impersonation, each input (
workload_identity_providerand others), checkout order