# Who holds your keys, and for how long?

> Should people, pipelines and apps carry long-lived keys or borrow short-lived roles, where does each secret live, and which code you didn't write runs next to them? Every credential answers three questions, usually by accident: who holds it, how long it works, and what else runs next to it. A long-lived key works from anywhere until someone revokes it, and outlives the person who made it. A borrowed role works for one session: a person through SSO, a pipeline through OIDC, a function through its own role, so there's nothing stored to leak. The secrets that can't be a role (a database password, a Stripe key, a model provider's key) go in the secret store, one job per key, read at runtime by reference. And the code you pull runs with all of them: a tag can be moved, so pin it to a SHA, a digest or a hash.

- A concept by Nicolas Dao (Cloudless Labs, Sydney): the decision before the tool
- Area: [Security, access and residency](https://cloudlesslabs.com/concepts/areas/security/index.md)
- Last reviewed: 9 November 2026, by Nicolas Dao
- Web page: https://cloudlesslabs.com/concepts/keys-and-secrets/

## What you'll be able to decide

Whether each person, pipeline and service holds a long-lived key or borrows a short-lived role; where each secret that must exist lives (the cloud's secret store for applications, a password manager for people) and how the code reads it; and what third-party code may run next to those secrets, pinned to what.

## In short

### The options

- **A. A long-lived key:** An access key or a full API key that works from anywhere, for whoever has it, until someone revokes it. The quickest to set up, and the hardest to get rid of.
- **B. Borrow a role:** Short-lived credentials issued on demand: SSO through Identity Center for people, OIDC for a pipeline, the function's own role for code on AWS. Nothing is stored, so nothing leaks or needs rotating. Tools: [AWS Organizations + IAM Identity Center](https://cloudlesslabs.com/stack/aws-organizations-identity-center/index.md) (My default), [switch-profile](https://cloudlesslabs.com/stack/switch-profile/index.md) (My default), [GitHub Actions](https://cloudlesslabs.com/stack/github-actions/index.md) (Use when it fits).
- **C. A secret store, read at runtime:** For the secrets that can't be a role: created with the stack, read by the code at runtime through a reference, each key scoped to one job (a Stripe restricted key, a spend-capped model key). Tools: [AWS Secrets Manager](https://cloudlesslabs.com/stack/aws-secrets-manager/index.md) (My default), [Stripe](https://cloudlesslabs.com/stack/stripe/index.md) (My default), [OpenRouter](https://cloudlesslabs.com/stack/openrouter/index.md) (My default).
- **D. Pin the code you pull:** Third-party code runs with your keys. Pin each piece to something that can't move: an action to a full commit SHA, an image to its digest, a package or a skill to its hash. Tools: [GitHub Actions](https://cloudlesslabs.com/stack/github-actions/index.md) (Use when it fits), [HappySkills](https://cloudlesslabs.com/stack/happyskills/index.md) (My default), [Self-hosted MCP servers](https://cloudlesslabs.com/stack/mcp-servers/index.md) (Use when it fits).

### The rule of thumb

No key where a role will do. Every secret that must exist goes in the secret store, scoped to one job and read at runtime. And nothing you pull runs next to them unpinned.

### How the answer changes with scale

- **One person, one AWS account:** SSO anyway. Identity Center costs nothing extra, and a key that never exists can't leak.
- **A team, a pipeline, several environments:** An OIDC role per environment, each in its own account; application secrets in Secrets Manager, created with the stack; a short list of actions pinned to commit SHAs.
- **Agents working on client systems:** Each agent gets its own credential, scoped to its job, and the code reads secrets at runtime, so no agent ever sees a value.
- **A system that holds every key you own: a gateway or a proxy:** Treat it like the vault it is: pinned by digest, the smallest possible role, outbound traffic limited to what it calls. Or let a managed service hold scoped, spend-capped keys instead.

## The problem, in plain words

Every system runs on credentials: a developer's access to AWS, the pipeline's right to deploy, the app's database password, its Stripe key, its model provider's key. Each one answers three questions, usually by accident. Who holds it? How long does it work? And what else runs next to it?

The accident is a long-lived key. Someone needs the CLI on day one, so an IAM user gets an access key. It works from anywhere, for whoever has it, until someone remembers to revoke it. It gets copied to a laptop, a repository secret, an environment file, and it outlives the person who created it.

Then there's the code you didn't write: the action a build pulls, the image a server runs, the package a laptop installs, the skill an agent loads. Each one runs with every key on the machine. Pull it by a tag, and whoever controls the tag decides what runs.

The LiteLLM compromise of October 2026 had all three in one story. It was [traced back to a key leaked in May and never revoked](https://cloudlesslabs.com/stack/guides/ai-gateway-choice/index.md), it reached people through tags like `latest`, and it stole every provider key stored in the proxy's database. The poisoned release was live for under three hours. That was enough.

## The picture

The same deploy pipeline twice, then a person and the app. The first lane isn't hypothetical: in March 2025, the tags of one third-party action were moved to a malicious commit that printed secrets into build logs, across thousands of repositories.

**Diagram:** Four lanes. A pipeline with a stored key: a long-lived access key kept as a repository secret, a third-party action pulled by tag, the tag moved to a malicious commit, and the key printed in a build log, valid from anywhere. A pipeline with a borrowed role: actions pinned to a commit SHA, GitHub's OIDC token exchanged for a short-lived role that can do one job, and nothing left once it expires. A person: SSO with MFA, short-lived credentials per account, and no access key on the laptop. The app: a secret created with the stack in Secrets Manager, a reference in the config, and the value read at runtime by the function's role.

- **A pipeline with a stored key:** Access key, stored as a repository secret → A third-party action, by tag → Tag moved to a malicious commit → The key in a build log, valid from anywhere. March 2025: moved tags printed secrets into build logs, across thousands of repositories.
- **A pipeline with a borrowed role:** Actions pinned to a full commit SHA → GitHub's OIDC token, exchanged → A short-lived role that can do one job → Expired: nothing left to steal. Nothing stored, so nothing to leak or rotate.
- **A person:** SSO through Identity Center, with MFA → Short-lived credentials, per account → No access key on the laptop. One sign-in, MFA, and off-boarding in one place.
- **The app's own secrets:** Created with the stack, in Secrets Manager → A reference in the config, not the value → Read at runtime by the function's role. For the secrets that can't be a role.

## The options

**A long-lived key.** The quickest to set up and the hardest to get rid of. A full [Stripe](https://cloudlesslabs.com/stack/stripe/index.md) secret key can do anything in the account, payouts included; an AWS access key in a repository secret works from anywhere and outlives whoever created it. Don't hand them out by default: each one is a rotation waiting for a leak, and you won't know every place it was copied to. Where there's no choice, keep it narrow. A [Worker](https://cloudlesslabs.com/stack/cloudflare-workers/index.md) has no IAM role, and CloudWatch's new [API key for traces](https://cloudlesslabs.com/weekly/2026-w46/index.md) never expires unless you say so. There: one IAM user per integration, so one leak is one revocation, an expiry wherever you can set one, and the value stored as a secret the moment it's created.

**Borrow a role.** No stored credential at all. A person signs in through [IAM Identity Center](https://cloudlesslabs.com/stack/aws-organizations-identity-center/index.md) and gets short-lived credentials for each account they may reach: one sign-in, MFA, and off-boarding in one place. A pipeline swaps its own identity for a role: on [GitHub Actions](https://cloudlesslabs.com/stack/github-actions/index.md), OIDC exchanges GitHub's token for a short-lived IAM role whose trust policy is pinned to the repository and branch, never a wildcard. Code on AWS uses its function's own role. Nothing is stored, so nothing leaks, and nothing needs cleaning up when someone leaves. This site deploys that way, and no AWS key is stored anywhere.

Setting up SSO for dozens of accounts in a terminal is tedious, so I built [switch-profile](https://cloudlesslabs.com/stack/switch-profile/index.md), my own tool (bias on the table): one keystroke to switch accounts, a device code on a box with no browser, and an exit code that tells an agent a human has to log in. In [the project's](https://cloudlesslabs.com/work/switch-profile/index.md) words, I built a tool so those habits cost nothing.

**A secret store, read at runtime.** Some secrets can't be a role: a database password, a webhook signing secret, a Stripe or model provider key. For a client on AWS, they live in [Secrets Manager](https://cloudlesslabs.com/stack/aws-secrets-manager/index.md): created by Pulumi with the stack, encrypted with the client's own KMS key, read by the code at runtime, and logged in CloudTrail on every read. Two habits make it work:

- **Pass references, not values.** The config holds the secret's name (`valueFrom` in ECS), and the code reads the value when it runs. A value copied into an environment variable, a template or a commit is plain text again.
- **One job per key.** Stripe's restricted keys, one per service and one per agent, each with only the permissions it needs. One [OpenRouter](https://cloudlesslabs.com/stack/openrouter/index.md) key per application with a spending limit. A fine-grained GitHub token for an agent, never a classic personal token.

There are two kinds of secret, and they belong in different places. People's logins (consoles, registrars, admin panels, 2FA codes) go in a password manager; application secrets go in the cloud's store, so nothing breaks when someone leaves or a subscription lapses. Which password manager, I haven't settled: [Dashlane](https://cloudlesslabs.com/stack/dashlane/index.md) works, but it's on my list to replace.

**Pin the code you pull.** A tag is a pointer, and pointers move. Pin every third-party action to a full commit SHA, every image to its digest, every package to its hash, and keep the list short. A lock file does it for a whole tree: [HappySkills](https://cloudlesslabs.com/stack/happyskills/index.md), my own product, records the exact version, commit and SHA-256 hash of every skill, and refuses to install anything whose hash doesn't match. For [MCP servers](https://cloudlesslabs.com/stack/mcp-servers/index.md), use the vendor's official server and read the code of anything community-made: the fake postmark-mcp BCC'd every email to an attacker.

Pinning has a price, and it's the point: every update, fixes included, becomes a change you make on purpose. When Node.js patched a critical bug in its Brotli decoder in October 2026, pinned functions and container images only got the fix when someone acted. So take third-party patches within days, and where the platform owns the runtime (a zip Lambda on the managed runtime), let it patch you.

## A concrete scenario

A company in Sydney runs its booking platform on AWS, ships from GitHub, takes payments with Stripe and has just added an AI assistant. A developer has left. Each credential, as an audit would find it:

| Credential | As built | If it leaks | The fix |
|---|---|---|---|
| Developers' AWS access | IAM user keys on laptops, the leaver's included | Works from anywhere until someone deletes it | SSO through Identity Center, then delete the IAM users |
| The deploy | An access key stored as a repository secret | The same, from any workflow that can read it | OIDC: a role per environment, trusted for one repository and branch |
| The pipeline's actions | Third-party actions, referenced by tag | A moved tag runs someone else's code with the pipeline's secrets | Pinned to full commit SHAs, and fewer of them |
| Stripe | The full secret key in a Lambda environment variable, also pasted into the assistant's config | Anything in the account, payouts included | Restricted keys, one for the API and one for the assistant, in Secrets Manager, read at runtime |
| Model provider keys | A self-hosted LLM proxy, updated through `latest` | One poisoned release takes every key in its database | Provider keys in Secrets Manager, one spend-capped key per application, and no self-hosted proxy |
| Registrar and DNS logins | A shared document | Whoever has the document controls the domain | A password manager, shared with the people who need it |

Two of the six were keys that never needed to exist: they're gone. The rest still exist, but each now does one job, lives in one place, and can be revoked without touching the others.

## The trade-offs

| | A long-lived key | A borrowed role | A secret store, by reference | Pinned code |
|---|---|---|---|---|
| If it leaks | Works from anywhere until someone revokes it | Expires on its own | One place to rotate, and every read in CloudTrail | A moved tag changes nothing: you still run the release you pinned |
| What it costs | Nothing to set up; a hunt for every copy after a leak | SSO and OIDC set up once; Identity Center has no extra charge | $0.40 a secret a month | Every update, fixes included, is a change you make |
| Where it goes wrong | Every place it was copied: laptops, repository secrets, environment files | A trust policy with a wildcard | A value copied into an environment variable, a template or a commit | A pin nobody reviews or updates |
| Use it for | Only where nothing else works, one per integration | Every person, every pipeline, every function on AWS | Every secret that can't be a role | Every third-party action, image, package and skill |

## The rule of thumb

**No key where a role will do.** People sign in through SSO, pipelines through OIDC, code on AWS through its own role. Every secret that must exist goes in the secret store, scoped to one job, and the code reads it at runtime by reference. People's logins go in a password manager, never next to the app's secrets. And nothing third-party runs next to those secrets unless it's pinned to something that can't move: a SHA, a digest or a hash.

## When the answer changes with scale

- **One person, one account:** SSO anyway. Identity Center costs nothing extra, and a key that never exists can't leak.
- **A team with a pipeline:** an OIDC role per environment, each in its own account; application secrets in Secrets Manager, created with the stack; a short list of actions pinned to commit SHAs. At 40 cents a secret, the bill is noise: a typical client app has a dozen secrets, so a few dollars a month.
- **Agents on client systems:** each agent gets its own credential, scoped to its job: a restricted Stripe key, a fine-grained GitHub token, a spend-capped model key. The code reads secrets at runtime, so the agent can deploy and debug without ever seeing a password, and its transcript has none to keep.
- **A system that holds every key you own:** a gateway, a proxy. Three companies' incidents in one October week had one pattern, in [The Stack Weekly](https://cloudlesslabs.com/weekly/2026-w44/index.md)'s words: "your keys sat in a system you don't run, next to everyone else's keys." If you run that system yourself, treat it like the vault it is: pinned by digest, the smallest possible role, and outbound traffic limited to the model providers. If you don't have to run it, don't.

## Where secrets end up in plain text

A secret store only helps while the value stays in it. Where it escapes to:

| Where | What goes wrong | Instead |
|---|---|---|
| An environment variable, a template, a commit | Plain text for anyone who can read the config or the history | A reference, read at runtime |
| Infrastructure state | Terraform's state files hold every secret in plaintext, including values marked sensitive | Pulumi encrypts secrets in state with KMS; a write-only argument keeps a value out entirely ([Pulumi or Terraform?](https://cloudlesslabs.com/stack/guides/pulumi-or-terraform/index.md)) |
| A private repository | Push protection, which blocks a commit containing a key, is on by default for public repos only | GitHub Secret Protection on client repos, or gitleaks in a pre-commit hook and in CI |
| An agent's transcript | Claude Code keeps sessions in plain text for 30 days, secrets the agent read included | Code that reads secrets at runtime, and deny rules for files like `.env` |
| A hosted console | Your key sits next to everyone else's | Your own account's secret store, and a rotation for any key you ever pasted there |

## Questions that change the answer

- Can it borrow a role instead of holding a key? People sign in through SSO, a pipeline through OIDC, a function through its own role.
- If this key leaked tonight, what could it do, and how many other things would you have to rotate with it?
- Where else does the value sit in plain text: an environment variable, a commit, infrastructure state, a build log, an agent's transcript?
- Is it a person's login or an application's secret? They belong in different places.
- What runs on the machine that holds the keys, and who decides its version: you, or whoever controls the tag?

Doesn't cover:

- How AWS accounts wall environments off from each other, so a leak in one can't touch the others: [One AWS account, or many?](https://cloudlesslabs.com/concepts/accounts-as-walls/index.md).
- Sign-in for your app's own users: that's [Better Auth or Cognito?](https://cloudlesslabs.com/stack/guides/sign-in/index.md).
- Where code nobody reviewed runs, so it can't reach your keys at all: [Where should code nobody reviewed run?](https://cloudlesslabs.com/concepts/sandboxing/index.md).
- Whether a release is safe in the first place. Pinning makes sure you run the release you reviewed; it doesn't review it for you.

## Check your call

- **A GitHub Actions workflow deploys to AWS with an access key stored as a repository secret, and uses several third-party actions referenced by tags.** Borrow a role and pin the code. OIDC exchanges GitHub's token for a short-lived role, its trust pinned to the repository and branch, never a wildcard. Pin each action to a full commit SHA, cut the ones you can do without, then delete the key.
- **An AI agent reads a client's Stripe account to answer billing questions.** A restricted key of its own, with only the permissions it needs, in Secrets Manager, read by the code at runtime so the value never appears in a prompt. Never the full secret key: it can do anything in the account, payouts included.
- **A Cloudflare Worker has to call an AWS API.** There's no IAM role on Workers, so calling AWS APIs directly means storing a key. Keep it narrow, one per integration, in Workers secrets. Better: call an AWS endpoint (a function URL with signed requests) instead of AWS APIs directly.
- **A team wants to self-host an LLM proxy so every model provider key sits in one place.** Skip it unless data residency forces it: one place for every key is exactly what a poisoned release goes after. Keep provider keys in Secrets Manager, read at runtime, with one spend-capped key per application. If residency forces it, pin the proxy by digest and give it the smallest possible role.

## The technology on this site

- [The LLM streaming proxy](https://cloudlesslabs.com/stack/guides/llm-proxy/index.md): the decision guide
- [Cloudflare AI Gateway, OpenRouter or LiteLLM?](https://cloudlesslabs.com/stack/guides/ai-gateway-choice/index.md): the decision guide
- [Pulumi or Terraform?](https://cloudlesslabs.com/stack/guides/pulumi-or-terraform/index.md): the decision guide
- [An AWS foundation for a client](https://cloudlesslabs.com/stack/use-cases/aws-foundation/index.md): the use case
- [An AI chat or LLM feature](https://cloudlesslabs.com/stack/use-cases/ai-chat/index.md): the use case
- [Building with AI agents](https://cloudlesslabs.com/stack/use-cases/ai-dev/index.md): the use case
- [switch-profile, open-source AWS account switching with SSO](https://cloudlesslabs.com/work/switch-profile/index.md): in our client work
- [HappySkills, the package manager for AI agent skills](https://cloudlesslabs.com/work/happyskills/index.md): in our client work
- [Week 44: LiteLLM 1.107.0, a credential stealer published as an update](https://cloudlesslabs.com/weekly/2026-w44/index.md): in The Stack Weekly
- [Week 46: CloudWatch takes OpenTelemetry traces with an API key](https://cloudlesslabs.com/weekly/2026-w46/index.md): in The Stack Weekly

## Related

- [Where may the data go?](https://cloudlesslabs.com/concepts/data-residency/index.md): read next
- [One AWS account, or many?](https://cloudlesslabs.com/concepts/accounts-as-walls/index.md): read next
- [Where should code nobody reviewed run?](https://cloudlesslabs.com/concepts/sandboxing/index.md): read next
- [How much freedom should an AI step get?](https://cloudlesslabs.com/concepts/ai-autonomy/index.md): read next
- [Coming in, or going out?](https://cloudlesslabs.com/concepts/private-access/index.md): the decision after it
- [Security, access and residency](https://cloudlesslabs.com/concepts/areas/security/index.md): the area, and the technology for each answer
- [Every concept](https://cloudlesslabs.com/concepts/index.md)
- [Map of every Markdown file on cloudlesslabs.com, with what each one answers](https://ask.cloudlesslabs.com)
