Secure GCP CI/CD Pipelines: Cloud Build, GitHub Actions, WIF, Binary Authorization, and Secrets
A CI/CD pipeline has privileged access to your production environment. Done right, it is the fastest and safest way to deliver software. Done poorly, it hands attackers a direct route in through your build system. This page covers the controls that close those gaps on GCP: keyless authentication, least-privilege identities, secrets handling, image verification, and audit monitoring.
Simple explanation
Your CI/CD pipeline is an automated system with privileged access to your infrastructure. It can build container images, push them to registries, deploy services, and read configuration. Think of it as a trusted robot that has a key to your server room.
If that robot stores its key as a file that can be copied, uses an identity with access to everything in the building, deploys whatever it is given without verifying what it is, or leaves no record of its actions, a single mistake becomes a serious incident.
Securing a CI/CD pipeline means four things: controlling how it authenticates (no long-lived key files), limiting what it is allowed to do (least privilege), verifying what it deploys (image attestation), and keeping a record of everything it does (audit logs). The rest of this page explains each of those in practice.
Why CI/CD pipeline security matters
Pipelines are high-value targets precisely because they already have legitimate access to production. Compromising a running application takes skill and gives limited persistence. Compromising the build system that deploys it gives an attacker the ability to deploy arbitrary code, read all environment secrets, and modify infrastructure, often using credentials that look exactly like normal pipeline activity.
Supply chain attacks work by targeting build infrastructure rather than applications directly. The attacker does not need to find a vulnerability in your code. They need a route into your build system, whether that is a leaked service account key, an overly permissive pipeline identity, or a compromised dependency. A CI service account with roles/editor whose key is stored as a GitHub Secret represents persistent undetected risk in many GCP projects.
The four most common weaknesses are:
- Service accounts with overly broad roles such as editor or owner, often set during initial setup and never revisited
- Service account key files stored as GitHub Secrets, in a secrets vault, or in CI environment variables
- Secrets hardcoded in configuration files, substitution variables, or committed to source control
- No image verification, so any image pushed to the registry can be deployed to production without checks
None of these require sophisticated exploitation. They represent common setup decisions that made sense at the time and become problems later.
A CI service account with roles/editor can create and delete any resource, modify IAM policies, and read secrets across the entire project. If that account’s key is stored in a GitHub Secret, a single leaked token gives an attacker broad, persistent project-level access. This combination is more common than most teams realise.
How it works
A secure GCP pipeline follows a clear sequence. Each step is either a control that prevents a bad outcome or a checkpoint that would catch one if it happened:
- A code change triggers the pipeline via a Cloud Build trigger or GitHub Actions workflow.
- The pipeline authenticates to GCP using a short-lived token from Workload Identity Federation (for GitHub Actions) or the native Cloud Build service account identity. No key files are involved.
- The build runs under a dedicated service account with only the permissions the pipeline needs. Not the default account, not a personal account, not an editor.
- Secrets are fetched from Secret Manager at runtime, not read from config files or environment variables set in the trigger.
- Tests and vulnerability scans run. The build fails if critical issues are found. The image does not move forward.
- The container image is pushed to Artifact Registry with an immutable digest tied to this specific build.
- An attestation is created and signed, recording that this image passed all required checks in the official pipeline.
- Binary Authorization verifies the attestation at deploy time. Images without a valid attestation are rejected before they reach the service.
- Cloud Audit Logs records every API call made by the pipeline identity, providing a complete traceable record.
Each layer is independent. Even if one fails or is bypassed, the others still provide protection. An image that bypasses the vulnerability scan cannot be deployed without an attestation. A compromised token cannot do more than the service account is permitted to do.
What a secure GCP pipeline should include
Dedicated least-privilege service accounts
Never use the default Cloud Build service account in production pipelines. The default account gets broad project permissions automatically and those permissions are easy to overlook. Create a dedicated service account per pipeline and grant only the roles it actually uses.
For a typical pipeline deploying to Cloud Run:
gcloud iam service-accounts create ci-deployer \
--display-name="CI/CD Deployer" \
--project=my-app-prod
gcloud projects add-iam-policy-binding my-app-prod \
--member="serviceAccount:ci-deployer@my-app-prod.iam.gserviceaccount.com" \
--role="roles/artifactregistry.writer"
gcloud projects add-iam-policy-binding my-app-prod \
--member="serviceAccount:ci-deployer@my-app-prod.iam.gserviceaccount.com" \
--role="roles/run.developer"
gcloud projects add-iam-policy-binding my-app-prod \
--member="serviceAccount:ci-deployer@my-app-prod.iam.gserviceaccount.com" \
--role="roles/iam.serviceAccountUser"Specify this service account in your Cloud Build trigger configuration. If the account is ever compromised, an attacker can only do what the pipeline is supposed to do. They cannot modify IAM policies, delete buckets, or access unrelated services.
Giving a CI pipeline broad IAM permissions is like giving a contractor a master key to every floor of your building when they only need access to one room. The job gets done either way, but if the key is copied, the scope of the problem is much larger than it needed to be.
Keyless authentication with Workload Identity Federation
If you use GitHub Actions to deploy to GCP, do not download a service account key file and store it as a GitHub Secret. This is the most common mistake in GitHub-to-GCP pipelines. A key file is a long-lived credential that does not expire unless you explicitly rotate it. If it leaks, you may not know until damage has been done.
Workload Identity Federation eliminates key files entirely. GitHub Actions exchanges a short-lived OIDC token for a temporary GCP access token. The token expires in one hour and is tied to the specific workflow run. There is nothing to rotate, nothing to accidentally commit, and nothing to steal in any permanent sense.
# Create a Workload Identity Pool
gcloud iam workload-identity-pools create github-pool \
--location=global \
--project=my-app-prod \
--display-name="GitHub Actions Pool"
# Create an OIDC provider for GitHub Actions
gcloud iam workload-identity-pools providers create-oidc github-provider \
--location=global \
--workload-identity-pool=github-pool \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
--project=my-app-prod
# Bind a specific repository to the service account
PROJECT_NUMBER=$(gcloud projects describe my-app-prod --format='value(projectNumber)')
gcloud iam service-accounts add-iam-policy-binding \
ci-deployer@my-app-prod.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/your-org/your-repo"Bind the WIF principal to a specific repository, not the entire organisation. The attribute.repository mapping means tokens from other repositories in the same org cannot impersonate this service account.
Secret Manager instead of hardcoded secrets
Secrets should never appear in cloudbuild.yaml, trigger substitution variables, Dockerfiles, or source code. Store them in Secret Manager and retrieve them at runtime using the availableSecrets block:
availableSecrets:
secretManager:
- versionName: projects/my-app-prod/secrets/api-key/versions/latest
env: 'API_KEY'
steps:
- name: 'python:3.11'
entrypoint: python
args: ['scripts/deploy.py']
secretEnv: ['API_KEY']Cloud Build fetches the secret from Secret Manager at runtime and injects it into the step environment. It automatically redacts secretEnv values from build logs. See Secrets in CI/CD Pipelines for the full pattern including GitHub Actions and the Terraform state file warning.
In shell scripts inside Cloud Build steps, reference secret env vars with $$VAR_NAME (double dollar sign). A single $ tells Cloud Build to substitute the value before the shell sees it, which bypasses log redaction. The double dollar sign passes the variable reference through to the shell instead.
Image scanning and vulnerability gates
Scan container images for known vulnerabilities as part of the build and fail on critical CVEs before the image progresses. This prevents known-vulnerable images from reaching Artifact Registry or production:
steps:
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'europe-west2-docker.pkg.dev/my-app-prod/api/api:$SHORT_SHA', '.']
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'europe-west2-docker.pkg.dev/my-app-prod/api/api:$SHORT_SHA']
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: bash
args:
- -c
- |
gcloud artifacts docker images scan \
europe-west2-docker.pkg.dev/my-app-prod/api/api:$SHORT_SHA \
--format=json | python3 scripts/check_vulnerabilities.pyScanning is asynchronous. Poll for completion before querying results. See Artifact Registry Best Practices for scanning configuration, cleanup policies, and repository-level access control.
Binary Authorization and deploy-time enforcement
Binary Authorization enforces a policy that only attested images can be deployed to Cloud Run or GKE. Your CI pipeline creates and signs an attestation after the image passes all required checks. When a deployment is initiated, Binary Authorization verifies the attestation and rejects any image that does not have one.
This is a technical enforcement layer that process controls alone cannot match. Even if someone with the right IAM permissions manually pushes an unscanned image to Artifact Registry, it cannot be deployed without a valid attestation signed by the official pipeline.
gcloud run services update api-service \
--region=europe-west2 \
--binary-authorization=defaultThink of Binary Authorization as a stamp on a passport. Your CI pipeline is the authority that issues the stamp after all checks pass. Cloud Run is the border control that checks for the stamp before letting the image through. An image built outside the official pipeline, or one that skipped the vulnerability scan, simply has no stamp and is turned away at the gate.
See the Binary Authorization page for policy setup, attestor configuration, and how to test in dry-run mode before enforcing.
Audit logs and monitoring
Cloud Audit Logs records every API call made by your CI service account. Enable Data Access audit logs for the services your pipeline uses and set up log-based alerts for unexpected actions: permissions changes, secret reads outside build hours, or deployments to unexpected regions.
gcloud logging read \
'protoPayload.authenticationInfo.principalEmail="ci-deployer@my-app-prod.iam.gserviceaccount.com"' \
--project=my-app-prod \
--limit=50A pipeline that never produces alerts is not necessarily safe, it may just be unmonitored. See Cloud Audit Logs for log types, retention settings, and alert configuration.
Environment separation and approval gates
Use separate service accounts, separate projects, and separate pipelines for each environment. A development pipeline should have no access to production resources. Promotion from staging to production should require either a manual approval step (supported natively in Cloud Deploy) or a separate trigger that only fires after all checks pass.
See Managing Environments in CI/CD and Dev vs Staging vs Production for environment isolation patterns.
When to use this
These controls apply whenever a pipeline has access to production resources. That covers:
- Cloud Build deploying to Cloud Run, GKE, or Compute Engine
- GitHub Actions deploying to any GCP environment
- Pipelines that read or write secrets, connection strings, or API keys at any point
- Workloads in regulated industries where audit trails and access controls are a compliance requirement
- Multi-environment promotion flows where a compromise in development could propagate toward production
- Teams currently storing credentials as GitHub Secrets or in plain-text configuration files
Even for small internal projects, three things are always worth doing: a dedicated least-privilege service account instead of the default, secrets in Secret Manager instead of config files, and audit logging enabled. These take under an hour to set up and close the most common failure modes without adding operational complexity.
Cloud Build vs GitHub Actions for GCP security
Both are valid choices for GCP deployment pipelines. The security considerations differ mainly around authentication and where the audit trail lives.
| Cloud Build | GitHub Actions | |
|---|---|---|
| Identity model | Runs natively as a GCP service account | Requires WIF or a key file to authenticate to GCP |
| Key file risk | None by default | Present unless WIF is configured from the start |
| Secrets handling | Secret Manager via availableSecrets, native integration | Secret Manager via WIF, or GitHub Secrets as fallback |
| Audit trail | Cloud Audit Logs natively for all GCP calls | GitHub Actions logs plus Cloud Audit Logs for GCP calls |
| Operational complexity | Lower for GCP-only teams | Higher initial setup, familiar for GitHub-native teams |
Cloud Build is simpler when your entire stack lives in GCP because it authenticates to GCP services natively. GitHub Actions is a reasonable choice if your team already uses GitHub for code review and wants a unified workflow, provided WIF is configured from the start rather than bolted on later.
The most common mistake with GitHub Actions is treating a key file stored as a GitHub Secret as equivalent to keyless auth. It is not. A service account key is a long-lived credential that does not expire on its own. See Why Service Account Keys Are Dangerous for a detailed explanation of the risks involved.
For a full Cloud Build walkthrough, see Cloud Build Overview. For GitHub Actions configuration with WIF, see GitHub Actions for GCP.
A simple secure pipeline example
Here is how the controls above connect in a realistic end-to-end pipeline deploying a containerised API to Cloud Run:
- A developer merges to
main. GitHub Actions triggers the workflow. - The workflow authenticates to GCP using Workload Identity Federation. A short-lived token is issued, scoped to this repository. No key file is involved.
- Docker builds the container image and tags it with the commit SHA. Using the SHA rather than a mutable tag means this image reference is immutable.
- The image is pushed to Artifact Registry using
roles/artifactregistry.writer. - A vulnerability scan runs against the pushed image. If any critical CVEs are found, the workflow fails here. The image does not progress.
- A Binary Authorization attestation is created and cryptographically signed, recording that this image passed vulnerability checks in the official pipeline.
- The pipeline deploys the image to Cloud Run. Binary Authorization checks for a valid attestation before the deployment proceeds. An image pushed manually without the attestation would be rejected at this point.
- Cloud Audit Logs records the full sequence: authentication, registry write, attestation creation, and deployment. A log-based alert fires if the CI service account takes any action outside the expected pattern.
The result is a delivery chain with no long-lived credentials, no hardcoded secrets, only attested images in production, and a complete audit trail. For the deployment half of this flow, see CI/CD Pipelines for Cloud Run and Deploying with Cloud Build.
Common beginner mistakes
Storing a service account key file as a GitHub Secret. A key file in a secrets store is still a long-lived credential. If the store is misconfigured, the secret appears in a log, or the key is never rotated, you have persistent exposure that does not expire on its own. Use Workload Identity Federation for GitHub Actions. There is nothing to steal.
Using the default Cloud Build service account. The default account gets broad project permissions that most pipelines do not need. Create a dedicated service account and specify it explicitly in your Cloud Build trigger.
Using one service account for every environment. A single CI identity with access to both staging and production means a compromise in the development pipeline can reach production. Keep identities and permissions separate per environment.
Granting
roles/editororroles/ownerto a CI service account. These roles can modify IAM policies, delete resources, and access data across the whole project. A deployment pipeline needs a specific set of deployment permissions, not project administration. Grant only what the pipeline actually uses.Passing secrets as substitution variables. Cloud Build redacts
secretEnvvalues from logs automatically, but does not redact substitution variables. A secret passed as_MY_API_KEYin a substitution appears in plain text in the build log. Always useavailableSecretsandsecretEnv.Trusting image tags instead of immutable digests. A tag like
latestorv1.2can be rewritten to point to a different image. Reference images by their SHA digest in production deployments to ensure you are running exactly what was tested and attested.Enabling controls but not monitoring them. Binary Authorization in dry-run mode and audit logs that nobody reviews are not providing real protection. After setting up controls, confirm they work by testing a rejection scenario and verify that alerts fire as expected.
Mixing build and deploy permissions in a single pipeline identity. A build identity needs registry write access. A deploy identity needs service deployment access. Splitting these into separate identities limits the blast radius if either is compromised. See Policy as Code for enforcing identity separation systematically.
Summary
- Use dedicated least-privilege service accounts per pipeline and environment, not the default Cloud Build account
- Use Workload Identity Federation for GitHub Actions to eliminate long-lived key files entirely
- Store all secrets in Secret Manager and retrieve them at runtime using
availableSecretsin Cloud Build - Run vulnerability scans in the pipeline and fail the build on critical findings before the image progresses
- Use Binary Authorization to enforce that only attested images built through the official pipeline can be deployed to production
- Monitor CI service account activity via Cloud Audit Logs and set up alerts for unexpected behaviour
- Keep environment identities and permissions separate so a development pipeline compromise cannot reach production
Frequently asked questions
Is Cloud Build secure by default?
Cloud Build runs builds in isolated VMs and does not retain state between runs, which is good. But the default service account gets broad project permissions automatically. You should always create a dedicated least-privilege service account and specify it in your trigger configuration rather than accepting the default.
Do I need Binary Authorization for Cloud Run?
For any production workload or regulated environment, yes. Binary Authorization is the only technical control that enforces which images can be deployed regardless of who initiates the deployment. Without it, anyone with the right IAM permissions can deploy any image, including one built outside the official pipeline. For small internal projects with no sensitive data it is optional, but still good practice.
What is the difference between GitHub Secrets and Workload Identity Federation?
GitHub Secrets stores values injected into workflow runs as environment variables. WIF is an authentication mechanism. A service account key file stored as a GitHub Secret is a long-lived credential that can be leaked or stolen. WIF replaces that key file with short-lived tokens that expire automatically. You can use WIF alongside GitHub Secrets for non-sensitive config values, but never store GCP service account keys in GitHub Secrets.
Which IAM roles should a CI service account actually have?
Only the roles the specific pipeline uses: roles/artifactregistry.writer to push images, roles/run.developer to deploy to Cloud Run, roles/iam.serviceAccountUser to act as the runtime service identity, and roles/secretmanager.secretAccessor if the build step reads secrets directly. Never grant roles/editor, roles/owner, or any project-level admin role.
How do I stop secrets appearing in build logs?
Use availableSecrets with secretEnv in cloudbuild.yaml. Cloud Build fetches the secret from Secret Manager at runtime and redacts known secretEnv values from logs automatically. Never pass secrets as substitution variables (the _VAR_NAME pattern) because those values are not redacted. In shell scripts, reference secret env vars with $$VAR_NAME (double dollar sign) to prevent Cloud Build treating the value as a substitution before it reaches the shell.