Security

Least Privilege for CI Bots That Commit and Deploy Code

An automation bot with broad write and unlimited deploy rights turns one bad run into a whole-system failure. Here is how to shrink the blast radius.

An automation bot that commits code, pushes branches, and triggers deploys is a machine account with production access and no human in the loop. The easy setup gives it a long-lived token, write access to the whole repository, and permission to deploy anything. That works on the first try, so it spreads. It also means a single misfire, a prompt injection, or a leaked token can rewrite any file and ship any service before anyone notices. This post is about shrinking that blast radius: give each bot its own identity, short-lived credentials, a narrow path allowlist, and signed output, so a bad run damages a corner instead of everything.

Why a bot's blast radius is larger than a person's

A human with broad access is still bounded by human speed. They open a few files, think, make a change, and a colleague reviews the pull request. Mistakes tend to be small and slow enough to catch.

A bot has none of those brakes. It acts in milliseconds, repeats the same action across every repository it can reach, and does not pause to wonder whether a change looks wrong. If the logic is flawed or the account is taken over, the same broad grant that made setup convenient now lets the attacker move at machine speed across the full scope of the token. The convenient default and the worst-case incident are the same permission set viewed on a good day versus a bad one.

So the design goal is not "trust the bot." A bot cannot be trusted in the sense a person can, because there is no judgment behind it, only rules and whatever input it was fed. The goal is to cap what a bot can do so that even a fully compromised run stays small. Treat every automation account as a limited capability, not a trusted actor.

Give each bot its own identity

The first mistake is sharing one powerful account across many jobs. A single machine identity that the test runner, the release job, the docs publisher, and three automation scripts all authenticate as is a single point of total failure. Any one of those jobs getting compromised hands over the union of everything the shared account can do.

Split them. Every distinct job gets its own identity with only the permissions that job needs. The identity that publishes documentation should not be able to deploy the payment service. The identity that runs tests should have read access and nothing more, because tests do not need to write anything to production.

Separate identities also make audit logs readable. When one account does everything, a log line saying "the ci account pushed a commit" tells you almost nothing. When the docs bot has its own name, an entry showing the docs bot writing outside the docs directory is an obvious anomaly you can alert on. Per-identity scoping turns your access log into a working intrusion signal.

Scope permissions to the exact path and resource

Least privilege means the grant matches the job, not the platform. A bot that updates a changelog needs write access to one file, not the whole tree. A bot that deploys one service needs deploy rights to that service, not to every service in the project.

Two limits do most of the work here. Restrict which resources a bot can act on, and restrict which paths it can write. A deploy identity should be bound to a specific service or namespace, so even a valid token cannot touch anything else. A commit bot should be constrained to a directory, either through a branch protection rule that rejects changes outside its lane, or through a check that fails the run when the diff strays.

# Per-bot permission model. Each identity gets only what its job requires.
identities:
  changelog-bot:
    repo_write: true
    path_allowlist:
      - "CHANGELOG.md"
      - "docs/releases/**"
    deploy: none

  service-deploy-bot:
    repo_write: false          # deploys, does not commit
    deploy:
      target: "web-frontend"   # this one service, nothing else
      environments: ["staging", "production"]

  test-bot:
    repo_write: false          # read-only; tests never write prod
    deploy: none

The point of writing the model out is that "give it write access" is a decision you can make more precisely. Almost every bot needs far less than a full grant, and the narrower grant costs nothing at runtime once it is set.

Broad access versus least privilege, side by side

The two setups look similar on a normal day. They diverge entirely on a bad one. The table below is the same automation bot under each model.

Property Broad access Least privilege
Identity One shared account for all jobs One identity per job
Repository write Entire tree Allowlisted paths only
Deploy scope Any service, any environment One named service and env
Credential lifetime Long-lived token, no expiry Minted per run, expires in minutes
What a leaked token gives up Full write and deploy, indefinitely One path or one service, until the token expires
A wrong run's reach Any file, any service The bot's lane only
Audit signal "the ci account did something" Named actor, out-of-lane action alerts
Rotation burden Manual, easy to forget None, credentials are ephemeral

The rows that matter most are the leak and wrong-run rows. Under broad access, one compromised token or one buggy run reaches everything. Under least privilege, the same failure is confined to a single path or a single service, and the short credential lifetime means a stolen token is close to worthless once the run ends.

Remove long-lived secrets with short credentials

A long-lived token stored in a secret is a standing liability. It does not expire, it can leak through a log or a compromised dependency, and if it does you often will not know, because a stolen token produces calls that look identical to the real bot. Rotation is a manual chore, so tokens accumulate and outlive the jobs that needed them.

The better pattern is credentials minted fresh for each run and expiring minutes later. Many automation platforms can present a signed identity assertion for a run, and a target system can be configured to trust that assertion for one specific caller and exchange it for a short-lived access token. No durable secret is stored, so there is nothing to leak, rotate, or find forgotten in a vault months later. The trust is pinned to the exact identity and often the exact branch, so an assertion from anywhere else fails the exchange before it reaches a real permission.

When a platform cannot mint per-run credentials and you are stuck with a stored token, make it a scoped one on a short rotation, and keep the scope as narrow as the least-privilege model above. A tightly scoped short-lived key is a defensible fallback. A broad, never-expiring one is the thing to design out.

Split content-write from deploy so one leak does not chain

A subtle trap is bundling every power into one credential because it is convenient. If the same token can both commit code and trigger a deploy, then leaking it hands over the entire chain: an attacker writes a malicious change and ships it in one move, with no second barrier.

Separate the tokens by function. The credential that writes content should not be able to deploy, and the credential that deploys should not be able to write content. Now a leak on the write side can dirty a file but cannot push it live on its own, and a leak on the deploy side can redeploy existing artifacts but cannot introduce new malicious code. The attacker needs both to complete an end-to-end compromise, which is a much higher bar than needing one.

# Two credentials, two jobs, no overlap.
content-writer:
  can_commit: true
  can_deploy: false      # writing alone cannot reach production

deployer:
  can_commit: false      # deploying alone cannot introduce new code
  can_deploy: true
  target: "web-frontend"

This is the same reasoning that keeps read and write apart, applied one level up. Splitting duties across credentials means no single stolen secret carries a run from source change all the way to live traffic.

Sign commits and artifacts so you can verify origin

Scoping limits what a bot can do. Signing lets you prove what a bot actually did. When a bot signs its commits and the artifacts it builds, every piece of output carries a verifiable mark of origin, and anything unsigned or signed by the wrong key is visibly foreign.

This matters because a narrow permission set still leaves room for a confused or hijacked bot to produce output within its lane. A signature turns that output into something you can check. A deploy step can refuse to ship an artifact that is not signed by the expected build identity, so an attacker who slips an unsigned binary into the pipeline gets rejected at the gate rather than shipped. A signed commit history means a forged commit claiming to be from the release bot fails verification, because the attacker does not hold the bot's signing key.

Signing is worth the setup when a bot's output flows into something that runs later. Combine it with the short-lived credentials above, keep the signing key itself minimal and separate, and origin verification becomes an automatic check rather than a manual trust decision.

The tradeoffs, and when to keep it simple

None of this is free. The honest cost is setup complexity and ongoing structure. One shared account with a broad token is a couple of clicks. The least-privilege version is several identities, a path allowlist per commit bot, a resource binding per deploy bot, a federation trust for short-lived credentials, split content and deploy tokens, and signing keys with their own verification step. That is real work, and the per-run credential trust in particular is easy to get subtly wrong in the loose direction, where it looks like it works but trusts more callers than you intended. Budget time to write those trust conditions precisely and to test that an identity from the wrong lane is actually rejected.

The payoff is that the cost is paid once and then largely disappears, while the risk it removes is standing and recurring. There is no token to rotate, no shared account whose scope quietly grows, and a compromised run is boxed into one path or one service instead of the whole system.

Judgment still applies. A throwaway sandbox with no real data and a lifespan of days does not need the full apparatus, because the blast radius is already small when the whole project will be deleted next week. A local script running against an emulator needs no production credential at all. The full model earns its cost when a bot has standing access to something that matters: a repository people depend on, a deploy path to real users, an artifact that runs in production. For any automation that commits or deploys on its own against live systems, the broad grant buys a few minutes today and a system-wide liability for as long as it exists. Least privilege costs a longer afternoon once and then keeps every bad run small.

Related posts