How to Handle Secrets Safely in GCP CI/CD Pipelines

A secret is any value your application needs that must never be public: database passwords, API keys, deployment tokens. In a CI/CD pipeline, secrets are needed to run tests, deploy code, and call external services. The problem is that pipelines are designed to be observable. Logs are widely accessible, config files get committed carelessly, and build output can be cached or exported in ways you do not expect. This page covers how to store, inject, and rotate secrets safely in GCP CI/CD workflows using Secret Manager and Workload Identity Federation.

What secrets in CI/CD pipelines actually means

A secret is a credential your software needs to do its job: a password that opens a database, a key that calls an external API, a token that grants access to a third-party service. You cannot hardcode these into source code without exposing them to anyone who reads the repository. You cannot paste them into build config files for the same reason.

A CI/CD pipeline is the automated process that runs every time you push code. It builds your application, runs tests, and deploys the result. To do that, it often needs the same credentials your running application needs, and sometimes more, because deployment itself requires elevated permissions.

The core rule is this: secrets live in a secure store, are fetched at runtime just before they are needed, and are injected only into the process that needs them. Never stored in files. Never written to logs. Never committed to version control.

In GCP, the secure store is Secret Manager. The pipeline fetches a secret immediately before the step that needs it, the value exists only in memory for that step, and it is discarded when the step completes.

Analogy

Think of it like a hotel key card. When you check in, the front desk does not write the room combination on your receipt. They issue a card that works for your specific room, for your stay only, and that stops working automatically when you check out. Your pipeline’s secrets should work the same way: specific to the step that needs them, temporary, and gone when the job is done.

Why pipeline secrets need special care

Application secrets in production are usually well-isolated. Secrets inside pipelines often are not, for a few structural reasons:

  • Build logs are widely readable. Anyone with access to Cloud Build or GitHub Actions can typically view build output. A secret that appears in a log is effectively a leaked secret.
  • Config files get committed. Trigger configurations, environment variable blocks, and YAML files get added to version control carelessly. A secret in a YAML file will remain in git history even after the file is edited.
  • Long-lived credentials get forgotten. A service account key file stored “temporarily” gets forgotten. Key files stay valid until someone manually revokes them.
  • Output is cached and shared. Build artifacts, Docker image layers, and test output can all be persisted and exported, potentially carrying secret values if those values were emitted anywhere during the build.

The two controls that address most pipeline secret risks are using Secret Manager for secret storage and injection, and using Workload Identity Federation instead of key files for GCP authentication.

How the safe secret flow works

Here is the full lifecycle of a secret in a well-configured GCP CI/CD pipeline:

  1. Code is pushed. A developer pushes to a branch and the pipeline trigger fires. Cloud Build receives a Pub/Sub event, or a GitHub push event starts a workflow.
  2. The pipeline authenticates as a service account. Cloud Build runs as a service account that has been granted specific IAM roles, including secretmanager.secretAccessor on exactly the secrets it needs, and nothing more.
  3. Secrets are declared but not fetched yet. The availableSecrets block in cloudbuild.yaml describes which secrets the build may access. This does not fetch anything. It is just a declaration.
  4. A step begins and its secret is fetched. When a pipeline step starts, Cloud Build calls Secret Manager and fetches the values listed in that step’s secretEnv. The fetch happens just-in-time.
  5. The secret is injected as an environment variable. The code running in that step reads process.env.DATABASE_PASSWORD or equivalent, without needing to know where the value came from.
  6. Logs are automatically redacted. Cloud Build replaces known secretEnv values in build output with ***.
  7. The secret is not propagated. The value does not appear in cloudbuild.yaml, Docker image layers, test output, or downstream artifacts.
  8. Rotation is transparent. When you add a new version to Secret Manager, any pipeline referencing /versions/latest automatically picks up the new value on the next run. No pipeline config changes needed.

Secrets in Cloud Build with Secret Manager

Cloud Build has native Secret Manager integration. Declare secrets in the availableSecrets block and reference them per step using secretEnv:

availableSecrets:
  secretManager:
    - versionName: projects/my-app-prod/secrets/database-password/versions/latest
      env: DATABASE_PASSWORD
    - versionName: projects/my-app-prod/secrets/api-key/versions/latest
      env: API_KEY

steps:
  - name: 'python:3.11'
    entrypoint: python
    args: ['scripts/migrate.py']
    secretEnv:
      - DATABASE_PASSWORD

  - name: 'gcr.io/cloud-builders/curl'
    secretEnv:
      - API_KEY
    script: |
      curl -H "Authorization: Bearer $$API_KEY" https://api.example.com/notify

A few things worth understanding about this pattern:

  • availableSecrets declares which secrets the build can use. It does not fetch anything immediately.
  • secretEnv on a specific step is what triggers the actual fetch. A step without secretEnv cannot access a secret even if it is listed in availableSecrets. This is intentional: you control which steps can see which secrets.
  • Cloud Build fetches the secret from Secret Manager just before the step runs, injects it as an environment variable, and automatically redacts it from build logs.
Tip

Use $$VAR_NAME (double dollar sign) when referencing a secret inside a script block. A single $ tells Cloud Build to treat the reference as a substitution variable, which fails if the variable is not declared in the substitutions block. The double dollar sign passes the reference through to the shell unchanged.

Granting Cloud Build access to a specific secret

Cloud Build runs as a service account. Grant roles/secretmanager.secretAccessor on the specific secret, not at the project level:

CB_SA=$(gcloud builds get-default-service-account --project=my-app-prod)

gcloud secrets add-iam-policy-binding database-password \
  --member="serviceAccount:${CB_SA}" \
  --role="roles/secretmanager.secretAccessor" \
  --project=my-app-prod
Warning

Do not grant roles/secretmanager.secretAccessor at the project level. A project-level binding lets the service account read every secret in the project. If your CI account is ever compromised, the attacker gets all of them. Grant the role on each secret by name so a compromised account exposes only what CI actually needs. This is least privilege applied to secret access.

GCP credentials in GitHub Actions

For GitHub Actions workflows that deploy to GCP, use Workload Identity Federation. No key file is created and nothing is stored in GitHub:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write

    steps:
      - uses: actions/checkout@v4
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider'
          service_account: 'ci-deployer@my-app-prod.iam.gserviceaccount.com'

The id-token: write permission lets the workflow request a signed OIDC token from GitHub. That token includes the repository name, branch, and workflow name. GCP validates it against your Workload Identity Pool configuration and issues a short-lived access token scoped to the specified service account. The token expires in one hour. There is no key file, nothing to store, and nothing to rotate.

For non-GCP secrets like npm tokens, external monitoring keys, or Slack webhooks, GitHub Encrypted Secrets are fine. Reference them as ${{ secrets.MY_SECRET }}. GitHub redacts known secret values from logs, but this masking is not exhaustive.

Warning

GitHub’s log masking is not a security boundary. If a secret appears URL-encoded, base64-encoded, or split across lines, it may not be redacted. Treat masking as a convenience, and design steps to avoid emitting secret values in log output in the first place.

When to use each approach

Use Secret Manager when:

  • Your pipeline needs database passwords, API keys, webhook tokens, or any other runtime credential
  • You need versioning, audit trails, or per-secret IAM bindings
  • You need automatic rotation with Pub/Sub notifications
  • Multiple pipelines or services share the same secret
  • The secret is used by both the pipeline and the running application — a single source of truth avoids drift between environments

Use Workload Identity Federation when:

  • Authenticating from GitHub Actions, GitLab CI, or any external CI system to GCP
  • You want to eliminate long-lived credentials entirely
  • Your external CI provider supports OIDC token issuance, which most modern providers do
  • You currently have a service account key file stored as a GitHub Secret and want to remove it

GitHub Encrypted Secrets are acceptable for:

  • Non-GCP third-party credentials that GitHub Actions needs but GCP does not
  • Simple tokens that do not need versioning, audit trails, or rotation
  • Credentials already scoped to the GitHub environment that do not grant access to GCP infrastructure

Avoid storing the credential at all when:

  • Authenticating GitHub Actions to GCP: WIF means there is no credential to store
  • Using Cloud Build: the service account identity itself is the credential
  • Configuring Cloud Run or GKE workloads via Terraform: pass the secret reference, not the fetched value

Secrets in Terraform configurations

Terraform can be tempting to use as a secret-fetching layer: pull the value from Secret Manager in a data source, then pass it as a string to a resource. The problem is that Terraform writes every value it processes to the state file. If the secret value passes through Terraform, it ends up in state in plaintext.

# Dangerous: the secret value gets written to Terraform state
data "google_secret_manager_secret_version" "db_password" {
  secret = "database-password"
}

resource "google_cloud_run_v2_service" "api" {
  template {
    containers {
      env {
        name  = "DATABASE_PASSWORD"
        value = data.google_secret_manager_secret_version.db_password.secret_data
      }
    }
  }
}

Pass the reference instead and let Cloud Run fetch the secret at container startup:

# Safe: the secret name is stored in state, not the secret value
resource "google_cloud_run_v2_service" "api" {
  name     = "api-service"
  location = "europe-west2"

  template {
    containers {
      env {
        name  = "DATABASE_PASSWORD"
        value_source {
          secret_key_ref {
            secret  = "database-password"
            version = "latest"
          }
        }
      }
    }
  }
}
Analogy

The difference between these two patterns is like the difference between a restaurant ordering cash from the bank and keeping it in the kitchen, versus giving the bank vault address to the till system so it draws on demand. In the first case, the cash is somewhere it does not need to be. In the second, it only ever exists in the vault.

The state file stores only the string “database-password”. Cloud Run fetches the actual value at runtime directly from Secret Manager, without the value ever passing through Terraform. If you do end up with secrets in state, store remote state in a GCS bucket with tight IAM controls and object-level logging. See Terraform state management for the setup. Never commit state files to version control.

Comparing the options

Secret Manager vs GitHub Encrypted Secrets

Secret ManagerGitHub Encrypted Secrets
Where secrets liveInside your GCP projectInside GitHub’s infrastructure
VersioningYes, multiple active versions with rotation supportNo, overwrite to update
Audit logsYes, every access logged in Cloud Audit LogsLimited, org audit log for changes only
IAM access controlPer-secret, per-principal bindingsRepository or organisation scope
Automatic rotationYes, via Pub/Sub notificationsNo
Works with Cloud BuildYes, native integrationNot directly
Works with GitHub ActionsYes, with WIF auth stepYes, natively
Best forGCP credentials, DB passwords, shared secretsNon-GCP tokens used only in GitHub Actions

Workload Identity Federation vs service account key files

Workload Identity FederationService account key file
Credential lifetime1 hour (short-lived OIDC token)Until manually revoked
Storage requiredNone, no file createdMust be stored somewhere
Rotation requiredNoYes, regularly and immediately on exposure
ScopeTied to specific repo, branch, workflowValid from anywhere by default
If leakedExpires in 1 hour with no action neededValid until revoked, requires immediate response
Setup complexityHigher, requires Workload Identity Pool setupLower, download a JSON key and paste it
Tip

The setup cost of WIF is a one-time investment. Once the Workload Identity Pool is configured, adding a new repository or workflow is a single IAM binding. See Workload Identity Federation for the full setup, and why service account keys are dangerous for the risks in detail.

Rotating secrets

Secrets should be rotated regularly and immediately if you suspect exposure. Secret Manager supports multiple active versions simultaneously, so rotation does not require downtime:

# Add a new version with the rotated value
echo -n "new-password-value" | gcloud secrets versions add database-password \
  --data-file=- \
  --project=my-app-prod

# After confirming all services are using the new version, disable the old one
gcloud secrets versions disable 1 \
  --secret=database-password \
  --project=my-app-prod

Pipelines that reference /versions/latest automatically pick up the new version on the next run. Cloud Run services pick up the new version on the next container start.

Secret Manager supports automatic rotation via Pub/Sub. Configure a rotation period and Secret Manager publishes a message when rotation is due, which can trigger a Cloud Function or Workflows execution to rotate the secret automatically. See rotating secrets automatically for that setup.

Common beginner mistakes

  1. Passing secrets as Cloud Build substitution variables. A step with _DB_PASSWORD=my-secret as a substitution will print that value in the build log. Cloud Build does not redact substitution variable values. Always use availableSecrets and secretEnv.

  2. Storing service account key files as GitHub Secrets. Key files are long-lived credentials that remain valid until manually revoked. Use Workload Identity Federation. Tokens expire in one hour with no files to create, store, or manage.

  3. Echoing secrets in scripts. Running echo $DATABASE_PASSWORD in a build step prints the value to the build log. Cloud Build redacts known secretEnv values, but only when they appear as-is. URL-encoded or base64-encoded forms may not be caught.

  4. Treating log masking as a security control. Log masking in Cloud Build and GitHub Actions is a convenience, not a guarantee. Secrets can still be exposed through artifacts, error messages, test output, or transformed representations. Design steps to never emit secret values.

  5. Granting project-level secret access to CI service accounts. Grant roles/secretmanager.secretAccessor on specific secrets by name. A compromised CI service account should expose only what CI legitimately needs, not every secret in the project.

  6. Pinning to a specific secret version number. Use /versions/latest. Pinning to a numbered version means every pipeline referencing that secret needs a manual config update when the secret rotates.

  7. Fetching secrets in Terraform data sources. Secret values fetched through google_secret_manager_secret_version data sources get written to Terraform state in plaintext. Pass the secret reference and let the target service fetch the value at runtime.

Frequently asked questions

How do I access Secret Manager secrets in Cloud Build?

Use the availableSecrets block in cloudbuild.yaml. Declare each secret with its Secret Manager resource path and an environment variable name. Then list that variable name in secretEnv on any step that needs it. Cloud Build fetches the value at runtime and automatically redacts it from build logs.

Should I store GCP credentials as a GitHub Actions secret?

No. Use Workload Identity Federation instead. WIF lets GitHub Actions authenticate to GCP using a short-lived OIDC token tied to the specific repository and workflow. No key file is created, nothing needs to be rotated, and there is nothing to leak. Service account key files stored as GitHub Secrets are long-lived credentials that remain valid until manually revoked.

What is the difference between Secret Manager and GitHub Encrypted Secrets?

Secret Manager is a dedicated GCP secrets store with versioning, audit logs, IAM-based access control per secret, and automatic rotation support. GitHub Encrypted Secrets are simpler, scoped to a repository or organisation and easy to set up. For GCP credentials, database passwords, and anything that needs an audit trail or rotation, Secret Manager is the right choice. GitHub Secrets are fine for non-GCP third-party tokens used only in GitHub Actions.

What is the most common secrets mistake in CI/CD pipelines?

Passing a secret value as a Cloud Build substitution variable rather than via secretEnv. Cloud Build redacts secretEnv values from logs, but does not redact substitution variable values. A secret passed as _DB_PASSWORD in the substitutions block will appear in plaintext in the build log.

What happens if I pin to a specific secret version instead of 'latest'?

When you rotate the secret by adding a new version, pipelines pinned to the old version number continue using the old value until you manually update the configuration. Pinning to /versions/latest means rotation is transparent. All pipelines pick up the new value automatically the next time they run.

Last verified: 25 March 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.