Security

Naming and Tagging Service Accounts So You Can Find Their Owner

Antonio Evans9 min read

A stylized diagram of a service account represented as a key or badge.
Key points

Key points

  1. 1Require three fields on every service account at creation: a structured name, an owner tag and a description carrying a ticket ID.
  2. 2A name like svc-payments-prod-batch tells a responder the service, the environment and the job in one glance, before anyone opens a console.
  3. 3Enforce the fields with a cheap CI linter so an untagged credential cannot reach production, rather than chasing stragglers in a spreadsheet later.
  4. 4Treat ownership as a field with a review date, and give every orphaned account an explicit owner (the platform team) instead of leaving it blank.

Why a self-expiring token still needs a name on it

Short-lived credentials are the right default. Google Cloud lets you mint access tokens for a service account with a short lifetime, and Kubernetes supports audience and time-bound service account tokens so a workload gets a credential that dies quickly (Google Cloud IAM, Red Hat OpenShift docs). Expiry bounds how long a leaked credential is useful. It does not tell you who to call when the credential is used wrongly, or who should approve a permission change, or which team's backlog a rotation belongs in.

That gap widens as machine identities multiply. Non-human identities already outnumber human users by a wide margin in large estates, and they are created continuously by cloud platforms, CI systems and integrations, which is why they are so often excluded from the controls that govern people (Veza, SecureSlate). Gartner-adjacent vendor research aside, the operational point stands: a service account is created by a deploy step, not by an HR event, so no offboarding process will ever catch it. The only durable link between a credential and a human is the metadata you write on it when it is born.

Google publishes the two essentials plainly: follow a naming and documentation convention, and create single-purpose service accounts (Google Cloud best practices). It also warns against disclosing sensitive information in service account email addresses, which is a reminder the name is semi-public. A convention that carries a team handle and a ticket reference, not a person's name or a customer name, keeps the identifier useful without leaking.

The three fields and what each one is for

Think of the three fields as answering three different questions at three different moments.

FieldExampleQuestion it answersWho reads it
Namesvc-payments-prod-batchWhat is this and where does it run?Anyone scanning a credential list, IAM console or alert
Owner tagowner:team-paymentsWhich group is accountable?Access reviewers, approvers, on-call rotations
DescriptionPayments nightly settlement batch. Owner: team-payments. Ticket: PAY-4821. Created by IaC.Why does it exist and who asked for it?Auditors, the next engineer, the reviewer who asks "can we revoke this?"

A fourth field earns its keep in systems that support it: a review date. Authentik, for example, sets an expiration date on a service account's generated app password and defaults it to 360 days when none is given, and its tokens expire by default and rotate automatically (authentik service accounts). Expiry is a good backstop. It is not a substitute for knowing the owner, because an expiring account nobody renews becomes an emergency at the worst time, and the person who would have renewed it is the person the owner tag identifies.

The decision rule for all three fields: if a responder reading the name, the tag and the description cannot tell which team to page and which ticket to read, the account is not ready to be created. That is the whole standard. Everything below is mechanics.

A naming convention that survives real consoles

Cloud identity systems impose their own constraints, so the convention has to work in the strictest one you use. Microsoft's Active Directory guidance recommends each service gets its own service account for auditing and security, and discourages privileged group membership for service accounts (Microsoft Learn). A name that encodes service and environment lets you find all of a service's accounts with one filter, and lets you see at a glance whether a batch job is running with production reach.

Use this shape:

svc-<service>-<env>-<purpose>[-<n>]
  • svc marks it as non-human. Every export becomes filterable.
  • <service> is the business capability, not the team name and not the repo name: payments, search, identity.
  • <env> is prod, stage, dev. Avoid production and prd, pick one and write it down.
  • <purpose> is batch, api, etl, deploy, monitor or similar. It is the verb the account performs.
  • [-<n>] is a two-digit suffix only when two accounts genuinely need the same tuple, for example during a blue-green migration.

Applied to real work:

Use caseNameNotes
Nightly settlement batchsvc-payments-prod-batchOne account, one job, production reach
Payments read APIsvc-payments-prod-apiSeparate from the batch so a batch leak cannot read customer data through the API
Staging deploysvc-payments-stage-deploySame service, limited environment
Shared Terraform runnersvc-platform-prod-deployPlatform team owns it, not the app team
A retiring account with a replacementsvc-search-prod-etl-02Suffix signals the pair, the ticket says which dies

The convention's real value shows up in a migration. When you replace a static key with short-lived credentials, the account name tells you which workloads depend on the credential. That is the visibility problem MavenHandle describes in offboarding: you cannot revoke what you cannot see, and shared servers multiply the blast radius to every tenant (MavenHandle). Naming is the cheap way to see.

Two rules that prevent trouble. First, no human names in the service account identifier, because Google explicitly flags email addresses as a disclosure surface and a person's departure is not a rename event. Second, no customer or tenant identifiers in the name; put those in the description or a tag, where access is narrower.

The owner tag: a team handle, never a person

The owner tag should hold a group identity, not an individual. Human owners change roles, and an owner tag that names a person is stale within a year. A team handle survives reorganisation because the group membership is the thing that changes, not the tag.

The reason this matters is written into how review campaigns work. Auditors want a named approver with a date, and an unapproved review is not evidence (SecureSlate). If the owner tag points at a group, the reviewer is the group's current lead, whoever that is. If it points at a person, the review stalls the first time that person moves teams.

Adopt a small controlled vocabulary for team handles and publish it next to the convention. Then tag with the same key everywhere: owner:team-payments, not Owner: Payments Team in one place and team_payments in another. Consistency is what makes a fleet-wide query possible. Veza describes ownership attribution as the step that links every non-human identity to a human owner and maps the true blast radius (Veza); the tag is the low-tech version of that, and it works even without a commercial tool.

For accounts with no obvious team, the tag is still mandatory. Assign owner:team-platform for shared infrastructure, owner:team-security for scanning and audit tooling, and owner:team-data for warehouse jobs. An orphaned identity should be owned by a named team, not by nobody. That single decision is the difference between an account you can eventually retire and one that sits in the estate for years.

WorkOS's agent checklist makes the same point about delegation: give every agent its own identity, separate from the user it serves, and log both identities plus the decision (WorkOS). When an AI agent acts through a service account, the account's name and tag are the only stable way to attribute an action after the fact.

The description field: the ticket is the receipt

The description is where the audit trail lives. It should contain, in one short paragraph, what the account does, who owns it and which ticket authorised it:

Payments nightly settlement batch. Runs in prod, writes to the ledger schema only.
Owner: team-payments. Ticket: PAY-4821. Provisioned by Terraform module svc-batch.
Rotation: quarterly, owner action.

Why a ticket ID and not just prose? Because a ticket is a durable, searchable record that survives people leaving. A reviewer who asks "why does this account have write access to the ledger" should be able to search the ticket, not schedule a meeting. Auditors sample exactly this evidence: SecureSlate's template tracks a removals log with ticket references because auditors often sample that tab directly (SecureSlate), and Otti's privileged access checklist calls for documenting findings, attaching evidence and recording approvals (Mitti).

Keep secrets out of the description. Application passwords and API keys are one-time-shown secrets, and the description is readable by anyone with read access to the identity object. UiChemy's guidance on WordPress application passwords is the right mental model: keep the credential only in the client's config, out of screenshots, tickets and repositories, and name one per tool and site (UiChemy). The same discipline applies to the description: reference the ticket, never the secret.

Enforce it with a CI linter

A convention that is only documented decays. A linter that runs on every change keeps the standard true without adding a review meeting. On the CI side, the rule is simple: any change that creates or modifies a service account must carry the three fields, and the pipeline fails otherwise.

If your accounts are defined as code, the check is a parser plus three assertions. Here is a small check for a Terraform plan or a YAML manifest, written to run in any language you already have in CI:

import re
import sys

NAME_RE = re.compile(r"^svc-[a-z0-9]+-(prod|stage|dev)-[a-z0-9]+(-\d{2})?$")
TEAM_RE = re.compile(r"^team-[a-z0-9-]+$")
TICKET_RE = re.compile(r"[A-Z][A-Z0-9]+-\d+")

def check(account):
    errors = []
    if not NAME_RE.match(account.get("name", "")):
        errors.append(f"{account.get('name')!r}: name does not match svc-<service>-<env>-<purpose>")
    if not TEAM_RE.match(account.get("owner", "")):
        errors.append(f"{account.get('name')!r}: owner tag must be a team handle, got {account.get('owner')!r}")
    if not TICKET_RE.search(account.get("description", "")):
        errors.append(f"{account.get('name')!r}: description must include a ticket ID like PAY-4821")
    return errors

failures = [e for a in load_accounts() for e in check(a)]
for e in failures:
    print(e)
sys.exit(1 if failures else 0)

Three habits make the linter actually stick:

  • Run it on the plan, not on the deployed resource, so the author sees the failure in their own pull request.
  • Make failures blocking for production directories, advisory elsewhere, so staging experiments do not get stuck.
  • Add a grandfather file listing every existing account that fails today, with its owner and a target date. The linter passes grandfathered names and fails anything new. This is the same approach as freezing a period and exporting data at a point in time during an access review (SecureSlate).

Do not rely on the cloud API alone to enforce this. Several systems let you attach free-text tags and descriptions but do not validate them. The linter is where you enforce the pattern, and the identity system is where you store the value.

When ownership changes, and what to do with orphans

Ownership changes constantly: reorganisations, team splits, a service being handed from one group to another. Treat the owner tag as mutable configuration, not as a historical fact. When a team changes, a one-off script re-tags its accounts, and the ticket for the handover sits in the description. This is why the description should say Ticket: PAY-4821 and later additions follow it, rather than being rewritten in place.

For orphaned accounts, use a three-step disposition that keeps the review honest:

  1. Find them. Query for accounts with no owner tag, a team handle that no longer exists in the directory, or a review date that has passed.
  2. Freeze them. Disable sign-in or rotate the credential before deciding anything. Google recommends disabling unused service accounts before deleting them, so you can back out a mistaken disable (Google Cloud best practices).
  3. Decide with evidence. Disable, rotate or delete, and record the decision against a ticket. MavenHandle's assume-compromise rule is the right default for anything with a suspected owner: rotate rather than trust, because rotation after exposure is the only safe assumption (MavenHandle).

Deletion is often the wrong first move because the account may be the only key to a working system. Application passwords and API tokens are the account in practice, and a token you cannot revoke because you cannot see it is the actual risk; WorkOS's checklist is blunt that revocation must be instant and cascading, and tested before you need it (WorkOS). An account with a stale tag is a revocation problem you have not discovered yet.

Where AI agents change the stakes

The same three fields apply to AI agent credentials, and the reason is that agents act without a human in the loop at the moment of the request. The Scalekit write-up on agent offboarding shows the failure directly: API keys configured for Claude Code, Copilot or Cursor live locally, outside the identity provider, so disabling an Okta account does not revoke them, and an agent on a schedule does not need to decide anything to keep acting (Scalekit). The account behind those keys is the thing that needs a name, a team and a ticket.

WorkOS's "five questions" for every agent request map onto the fields. "Who is the agent" is the name, "what is it allowed to do in general" is a scopes question answered next to the tag, and "can we prove what happened and undo access" is the ticket plus the revocation path (WorkOS). UiChemy's checklist adds the practical hedge for agent credentials: give the agent the lowest role that works, a dedicated account named for its purpose, and delete it when the project ends (UiChemy).

For teams running many tool connections through one place, putting the credits and keys behind a shared pool with per-person keys makes the owner question answerable from the billing and key records as well as the tag. Quanta for Teams (https://quantaintelligence.ai/for-teams) is built around that shape: one shared credit pool, a private skill library, admin controls and an API key per person, which gives each agent connection a human behind it. For a team that wants a second opinion on where its non-human identities are and who owns them, a free discovery call (https://quantaintelligence.ai/calendar) is the low-effort route.

FAQ

FAQ

How many fields do I really need at account creation?

Three are mandatory: a structured name, an owner team tag and a description with a ticket ID. A fourth, a review or expiry date, is strongly recommended because it forces an annual or quarterly decision, and many identity providers support native expiry (authentik service accounts). If you add nothing else, adding the review date on top of the three gives you a self-cleaning estate.

Should the owner tag point at a person or a team?

A team every time. Access evidence needs a named approver with a date, and the person changes while the team persists (SecureSlate). Put a group handle in the tag and let directory membership decide who is the current reviewer. If a team disappears, reassign its accounts to platform before the next review cycle closes.

What is the cleanest way to bring existing accounts into line?

Grandfather them and stop the bleeding first. Export the current inventory, record each failing account with its current owner and a target date, and make the CI check blocking only for new or changed accounts. Google's guidance to identify and disable unused service accounts before deleting them applies to the cleanup phase, not the enforcement phase (Google Cloud best practices). Bring the list down a few accounts at a time, each with a ticket.

How do I enforce the convention without slowing deploys?

Put the check on the pull request that defines the account, not on the runtime. A parser over a plan runs in seconds, fails for the author in the place where they can fix it, and requires no shared review meeting. Keep an exception list for experiments and document its expiry. This is the same model as freezing a period and exporting entitlements on a set date for review (SecureSlate).

What do I do with a service account nobody claims?

Freeze it, then decide. Disable sign-in or rotate the credential first, then look for the systems that fail. Give every unclaimed account a named owner, usually platform or security, with a ticket and an expiry date, so it appears in the next review instead of hiding. MavenHandle's assume-compromise default is the right instinct for anything you cannot attribute (MavenHandle).

Sources

Sources

  1. Create short-lived credentials for a service account
  2. Chapter 14. Using bound service account tokens
  3. What is non-human identity management
  4. Free Access Review Template (Excel)
  5. Best practices for using service accounts securely
  6. Service accounts
  7. Introduction to Active Directory service accounts
  8. What to Rotate When a Developer Leaves | MavenHandle
  9. The AI agent permissions checklist for SaaS apps (2026)
  10. Privileged Users Access Review Checklist - Mitti
  11. WordPress AI Agent Security Checklist
  12. When an Employee Leaves, Who Revokes Their AI Agent's ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles