~/2026/03/04/node-js-gcloud-rotating-service-account-keys-before-they-expire.md

Node.js: gcloud – Rotating Service Account Keys Before They Expire

---
author: 
date: 
read: 4 min
in:   [scripts, engineering]
tags: [nodejs, gcloud]
---

$ grep -n '^#' post.md

Service account keys are the credential everyone forgets about until a security review flags one that is four years old and still active. By default a user-managed key never expires, and once the JSON file is downloaded Google can't give it to you again, so the only copy lives wherever someone saved it. This script follows Google's rotation order in two runs. The first finds keys past an age threshold on a service account, mints one replacement, and hands it off through a local file or Secret Manager. Once the new key is deployed wherever the old one was used, a second run with --disable-old disables rather than deletes the old keys, so anything still referencing them fails loudly, with a one-command rollback.

Before you rotate: can you delete the key instead?

Google's key-management guidance is to pick a more secure alternative "whenever possible," and it lists them: an attached service account for code on Compute Engine, GKE, Cloud Run, and similar; Workload Identity Federation for workloads outside Google Cloud (GitHub Actions, AWS, Azure, on-premises OIDC or SAML providers). Rotation is the fallback for the keys you genuinely can't remove, such as a third-party SaaS tool that only accepts a JSON key.

Two organization policy facts change how this script behaves in practice:

  • Organizations created on or after May 3, 2024 enforce iam.managed.disableServiceAccountKeyCreation by default (the older equivalent is iam.disableServiceAccountKeyCreation). In those orgs keys.create fails until the project is exempted, which is the point: every key becomes a conscious exception.
  • iam.serviceAccountKeyExpiryHours makes new keys expire automatically. It accepts only allowed values: 1h, 8h, 24h, 168h, 336h, 720h, 1440h, 2160h (90 days), and applies only to keys created after the policy is set. Google recommends expiry for temporary access rather than production workloads, but a 90-day cap is a good backstop under a rotation job like this one.

A GitHub Actions deployment, for example, doesn't need a key at all. The Workload Identity Federation setup from Google's deployment-pipeline guide replaces it (the attribute condition is mandatory for GitHub, to stop tokens from other GitHub organizations being accepted):

bash
gcloud iam workload-identity-pools create github \
  --project=my-project --location=global --display-name="GitHub Actions"

gcloud iam workload-identity-pools providers create-oidc my-org \
  --project=my-project --location=global --workload-identity-pool=github \
  --issuer-uri="https://token.actions.githubusercontent.com/" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
  --attribute-condition="assertion.repository_owner=='my-org'"

gcloud iam service-accounts add-iam-policy-binding deployer@my-project.iam.gserviceaccount.com \
  --role=roles/iam.workloadIdentityUser \
  --member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/github/attribute.repository/my-org/my-repo"

Requirements

  • A supported Node.js LTS release (22 or later) and the googleapis package (npm install googleapis).
  • Application Default Credentials: gcloud auth application-default login interactively, or, on a schedule, the service account a Cloud Run job runs as (Cloud Scheduler only triggers the job; it doesn't lend the script an identity).
  • roles/iam.serviceAccountKeyAdmin on the project (or on the service account). It covers listing, creating, disabling, and deleting user-managed keys.
  • For --secret: an existing secret in the same project and roles/secretmanager.secretVersionAdder on it. Create the secret once with gcloud secrets create sa-reporting-key --replication-policy=automatic --project=my-project. Whoever collects the key from it needs roles/secretmanager.secretAccessor on that secret.
  • If the organization enforces iam.managed.disableServiceAccountKeyCreation or iam.disableServiceAccountKeyCreation, an exemption for this project, or the create call is refused.

Parameters

ParameterRequiredDescription
--projectYesProject ID that owns the service account.
--service-accountYesService account email.
--max-age-daysNoKeys older than this (by validAfterTime) are rotated. Default 90.
--secretOne ofSecret ID in the same project; the new key is added as a new version.
--outputOne ofLocal path for the new key file, written with mode 0600 (also applied to an existing file).
--disable-oldNoSecond run: create nothing, disable the keys over the threshold. Refuses if no enabled key within the threshold is left.
--dry-runNoReport what would happen; creates and disables nothing.

Usage

See what would be rotated without making changes:

bash
node rotate-sa-keys.js --project my-project \
  --service-account reporting@my-project.iam.gserviceaccount.com \
  --max-age-days 90 --dry-run

Mint a replacement for keys older than 90 days and store it as a Secret Manager version:

bash
node rotate-sa-keys.js --project my-project \
  --service-account reporting@my-project.iam.gserviceaccount.com \
  --max-age-days 90 --secret sa-reporting-key

Sample output (illustrative):

text
Found 3 user-managed key(s) on reporting@my-project.iam.gserviceaccount.com
Key 3f9a1c2b... is 142 days old, exceeds 90-day threshold
Key 77d0e4aa... is already disabled (SERVICE_ACCOUNT_KEY_DISABLE_REASON_USER_INITIATED)
Key ef567890... is 12 days old, within threshold
Created key 0b1c2d3e..., stored in Secret Manager version projects/123456789012/secrets/sa-reporting-key/versions/4
Old key(s) left enabled. Deploy the new key, then rerun with --disable-old.

Google doesn't recommend Secret Manager as the place a workload reads its service account key from: anything that can authenticate to Secret Manager already has an identity and should use it directly. Treat the secret as a hand-off point for the key's real destination, the out-of-cloud system that can't use an attached service account or federation. The person updating that system collects it once:

bash
gcloud secrets versions access latest --secret=sa-reporting-key --project=my-project > new-key.json

Once the new key is in place there, disable the old ones:

bash
node rotate-sa-keys.js --project my-project \
  --service-account reporting@my-project.iam.gserviceaccount.com \
  --max-age-days 90 --disable-old
text
Found 4 user-managed key(s) on reporting@my-project.iam.gserviceaccount.com
Key 3f9a1c2b... is 142 days old, exceeds 90-day threshold
Key 77d0e4aa... is already disabled (SERVICE_ACCOUNT_KEY_DISABLE_REASON_USER_INITIATED)
Key ef567890... is 13 days old, within threshold
Key 0b1c2d3e... is 1 days old, within threshold
Disabled old key 3f9a1c2b...

Script

javascript
#!/usr/bin/env node
/**
 * rotate-sa-keys.js
 *
 * Finds user-managed Google Cloud service account keys older than a threshold
 * and creates one replacement key. After the replacement is deployed, a second
 * run with --disable-old disables (not deletes) the old ones so anything still
 * depending on them fails loudly and can be rolled back.
 *
 * Reads:  service account key metadata via the IAM API (iam.googleapis.com).
 * Writes: the new key either as a new Secret Manager version (--secret) or to
 *         a local file with mode 0600 (--output); with --disable-old, disables
 *         old keys in place.
 *
 * Usage:
 *   node rotate-sa-keys.js --project <project-id> \
 *     --service-account <sa>@<project-id>.iam.gserviceaccount.com \
 *     --max-age-days 90 (--secret <secret-id> | --output ./new-key.json | --disable-old) [--dry-run]
 */

const fs = require('fs');
const { google } = require('googleapis');

const MAX_KEYS_PER_ACCOUNT = 10;
const DAY_MS = 24 * 60 * 60 * 1000;

function parseArgs(argv) {
    const args = {
        project: null,
        serviceAccount: null,
        maxAgeDays: 90,
        output: null,
        secret: null,
        disableOld: false,
        dryRun: false,
    };

    for (let i = 0; i < argv.length; i += 1) {
        switch (argv[i]) {
            case '--project':
                args.project = argv[++i];
                break;
            case '--service-account':
                args.serviceAccount = argv[++i];
                break;
            case '--max-age-days':
                args.maxAgeDays = Number(argv[++i]);
                break;
            case '--output':
                args.output = argv[++i];
                break;
            case '--secret':
                args.secret = argv[++i];
                break;
            case '--disable-old':
                args.disableOld = true;
                break;
            case '--dry-run':
                args.dryRun = true;
                break;
            default:
                throw new Error(`Unknown argument: ${argv[i]}`);
        }
    }

    if (!args.project || !args.serviceAccount) {
        throw new Error('--project and --service-account are required');
    }
    if (!Number.isFinite(args.maxAgeDays) || args.maxAgeDays < 1) {
        throw new Error('--max-age-days must be a positive number');
    }
    if (args.disableOld && (args.output || args.secret)) {
        throw new Error('--disable-old creates no key; drop --secret/--output');
    }
    if (!args.dryRun && !args.disableOld && !args.output === !args.secret) {
        throw new Error('Specify exactly one of --secret or --output');
    }

    return args;
}

function ageInDays(validAfterTime) {
    return Math.floor((Date.now() - new Date(validAfterTime).getTime()) / DAY_MS);
}

async function storeKey(args, auth, privateKeyData) {
    if (args.secret) {
        // privateKeyData is already base64, which is what SecretPayload.data expects.
        const secretManager = google.secretmanager({ version: 'v1', auth });
        const { data } = await secretManager.projects.secrets.addVersion({
            parent: `projects/${args.project}/secrets/${args.secret}`,
            requestBody: { payload: { data: privateKeyData } },
        });
        return `Secret Manager version ${data.name}`;
    }

    const json = Buffer.from(privateKeyData, 'base64').toString('utf8');
    const fd = fs.openSync(args.output, 'w', 0o600);
    try {
        fs.fchmodSync(fd, 0o600); // open's mode only applies when it creates the file
        fs.writeSync(fd, json);
    } finally {
        fs.closeSync(fd);
    }
    return args.output;
}

async function main() {
    const args = parseArgs(process.argv.slice(2));
    const auth = new google.auth.GoogleAuth({
        scopes: ['https://www.googleapis.com/auth/cloud-platform'],
    });
    const iam = google.iam({ version: 'v1', auth });
    const accountName = `projects/${args.project}/serviceAccounts/${args.serviceAccount}`;

    const { data } = await iam.projects.serviceAccounts.keys.list({
        name: accountName,
        keyTypes: ['USER_MANAGED'],
    });
    const keys = data.keys || [];
    console.log(`Found ${keys.length} user-managed key(s) on ${args.serviceAccount}`);

    const expired = [];
    let current = 0;
    for (const key of keys) {
        const keyId = key.name.split('/').pop();
        const days = ageInDays(key.validAfterTime);
        const expiry = key.validBeforeTime && !key.validBeforeTime.startsWith('9999')
            ? `, expires ${key.validBeforeTime}`
            : '';

        if (key.disabled) {
            console.log(`Key ${keyId.slice(0, 8)}... is already disabled (${key.disableReason || 'no reason'})`);
        } else if (days > args.maxAgeDays) {
            console.log(`Key ${keyId.slice(0, 8)}... is ${days} days old${expiry}, exceeds ${args.maxAgeDays}-day threshold`);
            expired.push(key);
        } else {
            console.log(`Key ${keyId.slice(0, 8)}... is ${days} days old${expiry}, within threshold`);
            current += 1;
        }
    }

    if (expired.length === 0) {
        console.log('No keys required rotation.');
        return;
    }

    if (args.disableOld) {
        if (current === 0) {
            throw new Error('No enabled key within the threshold; run without --disable-old to create one first');
        }
        if (args.dryRun) {
            console.log(`Dry run: would disable ${expired.length} key(s).`);
            return;
        }
        for (const key of expired) {
            await iam.projects.serviceAccounts.keys.disable({ name: key.name });
            console.log(`Disabled old key ${key.name.split('/').pop().slice(0, 8)}...`);
        }
        return;
    }

    if (keys.length >= MAX_KEYS_PER_ACCOUNT) {
        throw new Error(`Account already has ${keys.length} keys (limit ${MAX_KEYS_PER_ACCOUNT}); delete disabled keys first`);
    }
    if (args.dryRun) {
        console.log('Dry run: would create 1 replacement key.');
        return;
    }

    const created = await iam.projects.serviceAccounts.keys.create({
        name: accountName,
        requestBody: { privateKeyType: 'TYPE_GOOGLE_CREDENTIALS_FILE' },
    });
    const newKeyId = created.data.name.split('/').pop();
    const location = await storeKey(args, auth, created.data.privateKeyData);
    console.log(`Created key ${newKeyId.slice(0, 8)}..., stored in ${location}`);
    console.log('Old key(s) left enabled. Deploy the new key, then rerun with --disable-old.');
}

main().catch((error) => {
    console.error(error.message);
    process.exitCode = 1;
});

Notes

  • Before disabling anything, check whether the old key is still in use. Activity Analyzer reports the last authentication per key: gcloud policy-intelligence query-activity --activity-type=serviceAccountKeyLastAuthentication --project=my-project --query-filter='activities.full_resource_name="//iam.googleapis.com/projects/my-project/serviceAccounts/reporting@my-project.iam.gserviceaccount.com/keys/KEY_ID"'. It needs roles/policyanalyzer.activityAnalysisViewer, and recent events may not be included yet; check the observationPeriod in the result.
  • Disable, wait, then delete. A disabled key can be re-enabled if something breaks (gcloud iam service-accounts keys enable KEY_ID --iam-account=reporting@my-project.iam.gserviceaccount.com). I run a separate cleanup a week later with gcloud iam service-accounts keys delete KEY_ID --iam-account=..., which is permanent. The API doesn't record when a key was disabled, so the cleanup works from a list, not a timestamp.
  • Disabling a key does not revoke short-lived access tokens already minted from it. If the key leaked, the docs say to disable or delete the service account itself (or at least treat those tokens as live until they expire).
  • One replacement per run, regardless of how many expired keys the account has. The goal is a single current credential. Nothing is disabled in that run: Google's order is create, replace in every application, then disable and monitor, so --disable-old is a separate step you take after the deploy.
  • A service account can hold at most 10 keys. The script refuses to create an eleventh; delete disabled keys first.
  • Keys disabled by Google carry a disableReason of SERVICE_ACCOUNT_KEY_DISABLE_REASON_EXPOSED or ..._COMPROMISE_DETECTED. Google scans public repositories for leaked keys, and since June 16, 2024 it disables exposed keys by default unless iam.serviceAccountKeyExposureResponse has been set to WAIT_FOR_ABUSE (Google recommends DISABLE_KEY). Treat those as incidents, not rotation candidates.
  • The script lists USER_MANAGED keys only. SYSTEM_MANAGED keys are rotated by Google and can't be downloaded.
  • privateKeyData comes back base64-encoded. Secret Manager's payload.data also expects base64, so the script passes it through unchanged; only the file path decodes it.

References