Cloud Security Wire
AWS Azure GCP RSS
GCP Hardening Guide

GCP Service Account Keys: Why Downloaded Keys Are a Liability and How to Eliminate Them

A hardening guide covering the risks of GCP service account key files, how they get leaked, and how to migrate to Workload Identity Federation for keyless authentication across AWS, GitHub Actions, and on-prem workloads.

By Cloud Security Wire · ·
#gcp#service-account#workload-identity#keyless#iam

GCP service account keys are JSON files containing an RSA private key. They grant any bearer the full permissions of the service account — indefinitely, until manually rotated or revoked. Unlike cloud-native credential mechanisms that issue short-lived tokens tied to a runtime identity, downloaded key files are static secrets that persist long after they’re needed. They accumulate in CI/CD pipelines, developer laptops, Docker images, and Git repositories. This guide covers the risks, common leak vectors, and the modern alternative: Workload Identity Federation.

What a Service Account Key Looks Like

{
  "type": "service_account",
  "project_id": "my-project",
  "private_key_id": "abc123def456",
  "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----\n",
  "client_email": "myapp@my-project.iam.gserviceaccount.com",
  "client_id": "123456789012345678901",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token"
}

Anyone with this file can call gcloud auth activate-service-account --key-file=key.json or use the Google Cloud client libraries to authenticate as the service account. The key is valid until the private_key_id is deleted in IAM.

Why Downloaded Keys Are Dangerous

Long validity window

By default, service account keys do not expire. GCP allows setting a key_expiry_time field when creating keys via the API, but this requires explicit configuration and many organizations don’t use it. A leaked key from three years ago may still be valid.

No IP or resource binding

Unlike AWS IAM role assumptions which can be Condition-restricted by IP, VPC, or org membership, a GCP service account key has no inherent binding — it works from anywhere on the internet.

Git exposure is permanent

Leaked keys committed to Git exist in the commit history even after removal. Anyone with a clone of the repository at the time of the commit has a permanent copy. GitHub’s secret scanning detects GCP service account keys, but detection is not prevention.

Hard to audit active usage

While Cloud Audit Logs record API calls made using a key, the private_key_id field identifies which specific key was used. But organizations with dozens of service accounts and hundreds of keys rarely correlate these logs to detect stale-but-valid keys being used externally.

Common Leak Vectors

  1. CI/CD environment variables — Key stored as a CI secret, then accidentally printed in build logs or accessible via a compromised pipeline step.
  2. Docker image layers — Key COPY-ed into an image layer; even if deleted in a later layer, it remains accessible via docker history or image layer extraction.
  3. Terraform state files — State files stored in GCS (ironic) or locally sometimes include service account key material if the google_service_account_key resource is used.
  4. Application configuration filescredentials.json committed alongside application code, often by developers testing locally.
  5. Backup tarballs — VM snapshots or backup archives that include the key file.

The Hardening Path: Eliminate Keys Entirely

Step 1: Audit Existing Keys

# List all service accounts and their keys in a project
for sa in $(gcloud iam service-accounts list --format='value(email)'); do
  echo "=== $sa ==="
  gcloud iam service-accounts keys list \
    --iam-account="$sa" \
    --filter="keyType=USER_MANAGED" \
    --format="table(name.basename(),validAfterTime,validBeforeTime)"
done

User-managed keys (keyType=USER_MANAGED) are the ones you created and downloaded. System-managed keys are GCP-internal and not a concern.

Step 2: Disable Key Creation via Org Policy

gcloud resource-manager org-policies set-policy \
  --organization=ORGANIZATION_ID \
  policy.yaml

With policy.yaml:

constraint: constraints/iam.disableServiceAccountKeyCreation
listPolicy:
  allValues: DENY

This prevents creation of new downloadable keys. Existing keys continue to work until deleted — audit and rotate them before enforcing.

Step 3: Migrate to Workload Identity Federation

Workload Identity Federation (WIF) allows external identities — GitHub Actions OIDC tokens, AWS IAM roles, on-prem OIDC providers — to impersonate GCP service accounts without a key file. The mechanism uses OIDC or SAML:

For GitHub Actions:

# Create a Workload Identity Pool
gcloud iam workload-identity-pools create "github-pool" \
  --project="my-project" \
  --location="global" \
  --display-name="GitHub Actions Pool"

# Create a Provider within the pool
gcloud iam workload-identity-pools providers create-oidc "github-provider" \
  --project="my-project" \
  --location="global" \
  --workload-identity-pool="github-pool" \
  --display-name="GitHub Provider" \
  --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository" \
  --issuer-uri="https://token.actions.githubusercontent.com"

# Grant the pool permission to impersonate the service account
gcloud iam service-accounts add-iam-policy-binding "myapp@my-project.iam.gserviceaccount.com" \
  --project="my-project" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo"

In your GitHub Actions workflow:

- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: 'projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider'
    service_account: 'myapp@my-project.iam.gserviceaccount.com'

No key file. No secret stored in GitHub. The OIDC token issued by GitHub Actions is exchanged for short-lived GCP credentials at job runtime.

For AWS workloads:

gcloud iam workload-identity-pools providers create-aws "aws-provider" \
  --project="my-project" \
  --location="global" \
  --workload-identity-pool="aws-pool" \
  --account-id="123456789012"

AWS IAM roles can then impersonate GCP service accounts by exchanging AWS STS credentials for GCP access tokens.

Key Expiry as a Stopgap

If you can’t immediately migrate to WIF, enforce key expiry on all new keys:

gcloud iam service-accounts keys create key.json \
  --iam-account=myapp@my-project.iam.gserviceaccount.com \
  --key-file-type=json
# Note: key_expiry_time must be set via the REST API or Terraform, not gcloud

Via Terraform:

resource "google_service_account_key" "mykey" {
  service_account_id = google_service_account.mysa.name
  public_key_type    = "TYPE_X509_PEM_FILE"
  # No native expiry in Terraform resource — use org policy for max age
}

Set the constraints/iam.serviceAccountKeyExpiryHours org policy to force expiry:

gcloud resource-manager org-policies set-policy \
  --organization=ORGANIZATION_ID \
  expiry_policy.yaml
constraint: constraints/iam.serviceAccountKeyExpiryHours
listPolicy:
  allowedValues:
    - "720"  # 30 days maximum

Monitoring for Key Abuse

Enable Cloud Audit Logs for the IAM API and alert on:

protoPayload.methodName="google.iam.admin.v1.IAMAdmin.CreateServiceAccountKey"
protoPayload.methodName="google.iam.credentials.v1.IAMCredentials.GenerateAccessToken"

The CreateServiceAccountKey event fires when a new key is downloaded — monitoring this in real time gives immediate visibility into key creation outside of approved pipelines.

The path to keyless is not immediate for every organization, but the org policy to block new key creation combined with WIF for new workloads gives a clear migration path that meaningfully reduces long-tail credential exposure risk.

← All Analysis Subscribe via RSS