Back to blog
Best PracticesCloud SecurityGCPIdentity & AccessOperations & Compliance

Service Account Key Not Rotated: Fixing Stale GCP Keys

Learn why unrotated GCP service account keys older than 180 days are a security risk, how to rotate them safely, and how to go keyless with Workload Identity.

TL;DR

This check flags user-managed service account keys in GCP that are older than 180 days. Long-lived keys are a prime target for credential theft and lateral movement. Rotate them on a schedule, or better yet, stop creating them and switch to Workload Identity Federation.

Service account keys are one of the most abused credential types in Google Cloud. They are static, long-lived, and they end up everywhere: committed to repos, baked into container images, pasted into CI variables, and forgotten in developer laptops. When a user-managed key has been sitting unrotated for six months or more, you no longer really know who has a copy of it or where it lives.

The Service Account Key Not Rotated check (iam_sakeyrotation) looks at every user-managed service account key in your GCP projects and raises a finding when the key's creation date is more than 180 days in the past.


What this check detects

GCP service accounts can have two kinds of keys:

  • Google-managed keys — used internally by Google services, rotated automatically, and you never see the private key material. These are fine.
  • User-managed keys — JSON or P12 key files you create and download yourself. You are fully responsible for storing, distributing, and rotating these.

This check only concerns itself with user-managed keys. It reads the validAfterTime (creation timestamp) for each key and compares it against the current date. Any key created more than 180 days ago is reported.

Note: A service account key in GCP does not technically "expire" on its own. Once created, it remains valid until you explicitly delete it or disable the service account. That is exactly why age matters: an unrotated key is one that has been continuously usable for its entire lifetime.


Why it matters

A service account key is a bearer credential. Whoever holds the private key can authenticate as that service account and inherit every permission it has. There is no second factor, no session expiry, and no user interaction. That makes a leaked key far more dangerous than a leaked human password.

Here is how stale keys turn into incidents:

  • Repo and image leaks. A key gets committed to a private repo "temporarily" and never removed. The repo later goes public, gets forked, or is exposed through a misconfigured CI cache. Attackers scrape GitHub and public registries for exactly these JSON files.
  • Departed employees. Someone downloads a key to their workstation to debug a pipeline, then leaves the company. The key still works. Nothing forced it to roll over.
  • Lateral movement. An attacker who lands a foothold on one host finds a key file and uses it to pivot into other projects, often into accounts with broad roles like roles/editor.
  • No blast-radius reset. Rotating keys on a schedule is a natural way to limit how long a stolen credential stays useful. A key that has not rotated in 180+ days means a single past compromise can still be live today.

Warning: Old keys also correlate with old, over-broad service accounts. A key created two years ago often belongs to an account that accumulated permissions long before least-privilege was on anyone's radar. Treat a stale-key finding as a prompt to review the account's roles too.

From a compliance angle, CIS Google Cloud Foundations Benchmark explicitly recommends rotating user-managed keys within 90 days. A 180-day-old key is well past that line, so this finding will also show up in CIS-aligned audits.


How to fix it

Rotation here means creating a fresh key, updating everything that uses the old one, and then deleting the old key. The order matters, because deleting the active key before your workloads switch over will break authentication.

1. Find the offending keys

List keys for a service account and check their creation dates:

gcloud iam service-accounts keys list \
  [email protected] \
  --managed-by=user \
  --format="table(name, validAfterTime, keyType)"

To sweep an entire project for service accounts first:

gcloud iam service-accounts list \
  --project=my-project \
  --format="value(email)"

2. Create a new key

gcloud iam service-accounts keys create new-key.json \
  [email protected]

Store new-key.json in a secret manager, not in a repo or a plaintext config. If you use Secret Manager:

gcloud secrets versions add my-sa-key --data-file=new-key.json
rm new-key.json

3. Roll the new key into your workloads

Update every consumer of the old key to read the new one: redeploy the workload, refresh the CI/CD variable, or update the secret reference. Confirm the workload is authenticating successfully with the new key before moving on. Watching the IAM authentication logs or the key's last-used activity helps verify the cutover.

4. Delete the old key

Danger: Deleting a key is immediate and irreversible. Any workload still using it will start failing authentication the moment you delete it. Confirm the new key is live in every consumer before running this.

gcloud iam service-accounts keys delete KEY_ID \
  [email protected]

You can get KEY_ID from the last segment of the name field returned by the keys list command.

Tip: If you are unsure whether a key is still in use, disable the service account temporarily instead of deleting the key. If nothing breaks for a few days, you can safely delete the key (or the account). Re-enable with gcloud iam service-accounts enable if something does break.


The better fix: stop using keys at all

Rotation manages the risk, but it does not remove it. The strongest remediation is to eliminate user-managed keys entirely and use short-lived credentials instead.

  • Workloads inside GCP (GKE, Compute Engine, Cloud Run) should use the attached service account directly through the metadata server. No key file needed.
  • GKE workloads should use Workload Identity, which maps a Kubernetes service account to a Google service account without any exported keys.
  • External CI/CD and other clouds (GitHub Actions, GitLab, AWS, Azure) should use Workload Identity Federation, which exchanges an OIDC token for short-lived GCP credentials. No static key ever exists.
  • Human access should use gcloud auth login and short-lived impersonation, not downloaded keys.

A minimal Workload Identity Federation pool for GitHub Actions looks like this:

gcloud iam workload-identity-pools create github-pool \
  --location=global \
  --display-name="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"

Then grant the federated identity permission to impersonate your service account, and your pipeline authenticates with no JSON key in sight.


How to prevent it from happening again

Block key creation with an org policy

The most effective control is to stop user-managed keys from being created in the first place. Apply the org policy constraint iam.disableServiceAccountKeyCreation at the organization or folder level:

gcloud resource-manager org-policies enable-enforce \
  iam.disableServiceAccountKeyCreation \
  --organization=ORGANIZATION_ID

Warning: Enforce this broadly only after you have migrated existing workloads off keys, otherwise legitimate provisioning will start failing. Roll it out per-folder or per-project first, then widen the scope.

You can pair it with iam.serviceAccountKeyExpiryHours to cap the lifetime of any keys that are still allowed, so they expire automatically rather than living forever.

Enforce in IaC and policy-as-code

If you provision service accounts with Terraform, avoid the google_service_account_key resource entirely. When you must use it, add an OPA/Conftest or Sentinel rule that fails the plan. A simple Conftest example:

package main

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "google_service_account_key"
  msg := sprintf("Service account key '%s' is forbidden; use Workload Identity Federation", [resource.address])
}

Catch drift continuously

Org policy stops new keys, but keys created before you enforced it, or in projects outside the policy scope, still age out. A continuous check that re-evaluates key age on every scan, like this Lensix check, closes that gap and alerts you well before a key reaches 180 days.

Tip: Set your internal rotation target at 90 days rather than 180. By the time a key trips a 180-day alert, it has already been long-lived for two CIS rotation windows. Acting at 90 gives you margin to roll keys without a fire drill.


Best practices

  • Prefer keyless auth. Workload Identity and Workload Identity Federation remove the credential you would otherwise have to protect and rotate.
  • One key, one consumer. Never share a single key across multiple services. Shared keys make rotation painful and widen the blast radius.
  • Store keys in a secret manager. Never commit them, never put them in environment files checked into source control, and scan repos with tools like gitleaks.
  • Scope service accounts tightly. Grant only the roles the workload needs. Avoid roles/editor and roles/owner on automation accounts.
  • Monitor key usage. Use IAM activity logs and the key last-authentication data to spot unused keys, which are safe to delete and reduce your attack surface.
  • Rotate on a clock, not on incident. Build rotation into your deployment process so it happens automatically rather than as a reaction to a breach.

A 180-day-old service account key is a quiet liability. It works perfectly until the day someone else's copy starts working too. Rotate what you have, then invest in keyless authentication so you spend less time managing credentials that should not exist in the first place.