Skip to content

Your service account looks unused. It probably is not.

Kyro Security 8 min read

Somewhere in your Google Cloud console there is an insight telling you a service account has not been used in 90 days, and suggesting you disable or delete it.

It is probably right. It might also be describing an account that a running production workload depends on right now.

Both of those can be true at once, and the reason is worth understanding before you action the list.

What the check actually measures

The feature is service account insights, which Google labels Pre-GA. It flags an account when it has not authenticated in the last 90 days.

That is a real signal and a reasonable one. Every call to a Google Cloud API authenticates as some principal, and that authentication is recorded. If nothing has authenticated as this account in three months, that is genuinely suspicious.

Read the sentence carefully, though. It is not “has not done anything.” It is “has not authenticated to a Google Cloud API.”

Those are the same thing only if everything your workload does is a Google Cloud API call.

Which, for a lot of workloads, it is not

Picture a service running in GKE, using Workload Identity Federation for GKE to impersonate a Google service account. Its Kubernetes ServiceAccount is bound to that account:

role:   roles/iam.workloadIdentityUser
member: serviceAccount:PROJECT_ID.svc.id.goog[NAMESPACE/KSA_NAME]

That impersonation path is the one most clusters are on. Google’s current best practice is the other one: reference the Kubernetes ServiceAccount directly as an IAM principal, with a principal:// identifier and no intermediate service account at all. On that path none of what follows applies, because there is no service account left to flag. Impersonation stays documented and supported, because direct access still carries per-API limitations, so plenty of clusters will be on it for a long time yet. If yours is one, read on.

Now think about what that service spends its day doing.

It reads and writes to its own Postgres. It calls two internal services over the cluster network. It hits a model endpoint. It pulls from a bucket in another cloud, because that is where the data landed. Maybe once a night it writes a summary to Cloud Storage.

Every one of those is real work. Exactly one of them authenticates to a Google Cloud API.

If the nightly job is paused for a quarter, or moved, or the bucket write gets refactored away, the account goes quiet by this measure while the service keeps running perfectly. Ninety days later it appears on a list of things to clean up.

Google is precise about what resets the clock:

Whenever a service account or key is used to call a Google API, including an API that is not part of Google Cloud, it generates an authentication activity.

Tools to understand service account usage, Google Cloud documentation.

Failed calls count. Calls to Google APIs outside Google Cloud count. What does not count is everything else the workload does all day, because none of it is a call to a Google API. A pod can saturate its node for a quarter and produce nothing this measure can see.

Delete the account and the pod loses its identity. Not immediately, and not loudly: the GKE metadata server caches access tokens, and those are good for an hour by default. It fails the next time it needs a new one.

Google says this itself

This is not a gotcha, and it is not a criticism of the feature. The documentation is upfront about it:

We recommend cross referencing service account insights with Cloud Monitoring service account usage metrics before you disable or delete a service account.

Service account insights, Google Cloud documentation.

That sentence is worth reading twice, because it tells you the two signals are not the same. Insights and Activity Analyzer do not track authentication against Google APIs that sit outside Google Cloud. The Cloud Monitoring usage metrics do, including those APIs. That is the whole reason for the cross reference.

There is one more stated gap that neither side covers: requests authenticated by API keys bound to a service account are not recorded in the usage metrics at all. Google’s own conclusion is that “these service accounts might be reported as unused.”

Then there is the arithmetic of the cross reference itself. The insight looks back 90 days. Cloud Monitoring, per its own documentation, “retains service account metrics for 6 weeks.” The second source runs dry around six weeks into the window the first source is judging. For anything that last authenticated two months ago, the check you have been told to run has nothing left to say.

A tool telling you to check its output against a second source before acting is telling you something about the confidence of that output. It is worth doing. It is also worth knowing that doing it fully is not possible.

The second thing, which is quieter

There is a related problem in the same data, and it does not announce itself with a recommendation you can decline.

When a GKE pod uses Workload Identity Federation for GKE, the call is made as the Google service account. The audit log records that the service account called storage.objects.get, which is correct and which is what the usage analysis reads.

The interesting part is that the log knows more than that. For GKE workloads it also carries serviceAccountDelegationInfo[].principalSubject, and inside that string is this:

serviceAccount:PROJECT_ID.svc.id.goog[NAMESPACE/KSA_NAME]

Which is the same shape as the IAM binding further up this page. The namespace and the Kubernetes ServiceAccount are sitting in the log, in a field the usage analysis does not look at. It will not tell you the pod or the deployment, but it is a good deal more than nothing, and it is being ignored rather than missing.

Two things about where those entries live. Admin Activity logs always carry the field and cannot be turned off, so for anything that writes configuration it is reliably there. Data Access logs, which is where most ordinary data-plane calls land, are disabled by default for nearly every service, so on a lot of clusters those entries are never written at all.

Which means the answer is partly sitting in a log you cannot switch off and nobody is reading, and partly in one most people never switched on.

Now suppose five workloads share that service account, which is a normal thing to have done. They produce one usage profile between them.

If workload A needs storage.objects.delete and the other four do not, the account keeps the permission. It is used, after all. Four workloads stay over-permissioned and nothing in the data can tell you that, because from the outside all five look like one very busy identity.

Google’s own guidance is to create single purpose service accounts, and this is one of the reasons why. Every workload you attach to an existing account is a workload whose usage you can no longer see on its own.

One root, two symptoms

Both of these come from the same place.

The evidence is attributed to the identity that made the call. Your risk lives at the workload that holds the identity. Those are different things, and everything that goes wrong here goes wrong in the gap between them.

Half of the bridge exists. The audit log carries the namespace and the ServiceAccount, and the IAM binding carries them too. Nothing in the system reads either one and turns it into a relationship you can act on, so that half is sitting there unused rather than missing.

The other half is not sitting anywhere. Read every audit log in the project perfectly and you learn what the workload did to Google. You learn nothing about the Postgres, the internal services, the model endpoint, or the bucket in the other cloud, because none of those produce a cloud audit record at all. Those are the dependencies that decide whether trimming the role breaks anything, and the cloud has never seen them.

So the tooling can tell you, accurately, that an identity has been quiet. It cannot tell you whether a workload depends on it. It can tell you, accurately, that a permission was exercised. It cannot tell you which of the five things behind that identity exercised it, or what happens to the other four if you take it away.

The question that is left

None of this makes the recommendations wrong. Most of them will be correct, and acting on them will genuinely reduce your exposure.

It just means the list is a starting point rather than a work queue. Every row on it comes with a question the row itself cannot answer:

If I delete this, what stops working?

The cloud’s own record gets you partway. It can name the workload behind the identity, the day something bothers to read a field it already writes. It cannot tell you what that workload depends on, because most of what a workload depends on never touches a Google API and therefore never shows up.

Which points at where the answer actually is. Not in a better reading of the identity, which is what every tool here is already doing, but in watching the workload: what it reaches for, what answers, and who goes quiet if the permission disappears. The identity tells you what was allowed. Only the workload tells you what was needed.

See what Kyro sees on your own cluster

One read-only DaemonSet, no workload ever waits on it. Get a walkthrough of what the Kyro sensor reports for your identities.

Get early access