~/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: Tom Lasswell 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.disableServiceAccountKeyCreationby default (the older equivalent isiam.disableServiceAccountKeyCreation). In those orgskeys.createfails until the project is exempted, which is the point: every key becomes a conscious exception. iam.serviceAccountKeyExpiryHoursmakes 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):
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
googleapispackage (npm install googleapis). - Application Default Credentials:
gcloud auth application-default logininteractively, 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.serviceAccountKeyAdminon 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 androles/secretmanager.secretVersionAdderon it. Create the secret once withgcloud secrets create sa-reporting-key --replication-policy=automatic --project=my-project. Whoever collects the key from it needsroles/secretmanager.secretAccessoron that secret. - If the organization enforces
iam.managed.disableServiceAccountKeyCreationoriam.disableServiceAccountKeyCreation, an exemption for this project, or the create call is refused.
Parameters
| Parameter | Required | Description |
|---|---|---|
--project | Yes | Project ID that owns the service account. |
--service-account | Yes | Service account email. |
--max-age-days | No | Keys older than this (by validAfterTime) are rotated. Default 90. |
--secret | One of | Secret ID in the same project; the new key is added as a new version. |
--output | One of | Local path for the new key file, written with mode 0600 (also applied to an existing file). |
--disable-old | No | Second run: create nothing, disable the keys over the threshold. Refuses if no enabled key within the threshold is left. |
--dry-run | No | Report what would happen; creates and disables nothing. |
Usage
See what would be rotated without making changes:
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:
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):
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:
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:
node rotate-sa-keys.js --project my-project \
--service-account reporting@my-project.iam.gserviceaccount.com \
--max-age-days 90 --disable-old
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
#!/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 needsroles/policyanalyzer.activityAnalysisViewer, and recent events may not be included yet; check theobservationPeriodin 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 withgcloud 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-oldis 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
disableReasonofSERVICE_ACCOUNT_KEY_DISABLE_REASON_EXPOSEDor..._COMPROMISE_DETECTED. Google scans public repositories for leaked keys, and since June 16, 2024 it disables exposed keys by default unlessiam.serviceAccountKeyExposureResponsehas been set toWAIT_FOR_ABUSE(Google recommendsDISABLE_KEY). Treat those as incidents, not rotation candidates. - The script lists
USER_MANAGEDkeys only.SYSTEM_MANAGEDkeys are rotated by Google and can't be downloaded. privateKeyDatacomes back base64-encoded. Secret Manager'spayload.dataalso expects base64, so the script passes it through unchanged; only the file path decodes it.
References
- Best practices for managing service account keys
- Create and delete service account keys
- Disable and enable service account keys
- Restrict service account usage (key constraints and expiry values)
- Secure-by-default organization policies
- Workload Identity Federation with deployment pipelines
- Activity Analyzer: service account key authentication
- Automatically disabling leaked service account keys (Google Cloud blog)
- Secret Manager access control
- IAM API discovery document (ServiceAccountKey fields, keys methods)