Skip to content
Back to blog
8 min read

The Tokens I Don't Have

The Tokens I Don't Have

The credential you never issued can’t leak.

There’s a particular kind of security work that produces nothing you can point at. You didn’t fix a vulnerability, because there wasn’t one yet. You didn’t respond to an incident, because it didn’t happen. All you did was remove something that was sitting there, quietly, being a liability.

The clearest example I know is the long-lived token in your CI secrets.

Almost every pipeline has a few. A publish token so the release job can push to a registry. A cloud key so a deploy step can reach your infrastructure. A personal access token because something needed to talk to something else. They get pasted in once, they work, and then nobody thinks about them again for two years.

That’s the problem. Not that a token is dangerous the day you create it, but that it keeps being dangerous every day afterward, without anyone deciding that it should.

What a stored secret actually costs

Be precise about the liability, because “secrets are risky” is too vague to act on.

It doesn’t expire. A token you created in 2024 authenticates exactly as well today. It outlives the project that needed it, the job it was scoped for, and frequently the person who created it.

It’s as scoped as whoever created it could be bothered to make it. Under time pressure, at the end of a task, with a UI offering a broad default, people pick the broad default. That’s not a character flaw; it’s what happens when the narrow option requires reading documentation and the wide one is preselected.

It gets copied. Once it exists, it goes wherever it’s needed. Another repo, a second workflow, someone’s local .env for debugging. Each copy is another place it can escape from, and there is no reliable way to enumerate them later.

You’ll learn it leaked from someone else. Tokens rarely announce themselves. You find out from a registry’s security team, an automated scanner, or a bill.

Set against that: a credential that never existed has no expiry to track, no scope to get wrong, no copies to inventory, and nothing to leak.

The one you already trust and never think about

Here’s what convinced me this was tractable rather than aspirational. I went looking through the estate for stored secrets and found that by far the most-used credential in every workflow is one nobody would call a secret at all.

GITHUB_TOKEN appears in 43 places across my repositories. I have never created one, never rotated one, and never pasted one anywhere.

It’s minted automatically when a job starts, it dies when the job finishes, and its permissions are declared in the workflow file next to the code that uses them:

permissions:
  id-token: write
  contents: read

That’s the whole model, already running in your pipelines, already trusted, already boring. Short-lived, narrowly scoped, issued to a job rather than held by a person, with its authority visible in the diff rather than buried in a settings page.

Once you notice that, the stored tokens sitting beside it start looking less like a necessity and more like the ones nobody has gotten around to replacing.

Killing the publish token

The npm publish token is the one I’d most like people to go delete after reading this, because the replacement is genuinely finished and most projects haven’t adopted it.

OIDC trusted publishing lets GitHub Actions authenticate to npm by proving which workflow, in which repository is running, rather than by presenting a stored password. You configure the trust on npmjs.com once, under the package’s Settings, naming the repo and workflow allowed to publish it. Then the workflow needs no token at all:

permissions:
  id-token: write
  contents: read

# ...

- name: Publish to npm
  run: npm publish --access public

That’s the real publish job from one of my packages, and the thing worth noticing is what isn’t there. No NODE_AUTH_TOKEN. No secrets.NPM_TOKEN. Nothing in the repository’s secrets to rotate, leak, or forget about.

Provenance comes along for free. Under trusted publishing, npm records a verifiable attestation of which workflow built the artifact, so consumers can check that a package actually came from the repository it claims to. You no longer pass --provenance, because there’s no longer a case where you wouldn’t want it.

Two version requirements will bite you, and neither is obvious from the error you get:

Trusted publishing needs npm 11.5.1 or newer, which is newer than the npm bundled with Node. So the workflow upgrades the CLI before publishing, as its own step. If you skip this, the failure looks like an auth problem rather than a version problem, which sends you off debugging the wrong thing entirely.

It also needs Node 22.14.0 or newer. I run 24, which is what npm’s own documentation suggests.

The other adjustment is conceptual. The trust relationship lives on npm’s side, not in your repository, so the thing granting publish rights is no longer visible in your codebase. That’s a real tradeoff. You gain a credential nobody can steal from your CI, and you accept that a piece of your deployment configuration now lives in a vendor’s settings page.

Let the tool own its own login

The second pattern is smaller and applies far more often than the first.

When you build something on top of a vendor’s CLI, there’s a temptation to have your tool handle authentication: read the token from an environment variable, keep it in a config file, pass it through to the API. It feels more capable that way.

Don’t. Let the CLI own its auth, and shell out to it.

I have a wrapper around doctl that turns long DigitalOcean incantations into short verbs. It never talks to the DigitalOcean API directly, and it never handles a DigitalOcean token. doctl auth init already solved that problem, and the wrapper simply doesn’t participate:

doctl owns authentication, so the wrapper never sees your token.

That single design decision removes an entire category of bug. The tool can’t log a credential to a debug trace, can’t write one to a temp file, can’t leak one in an error message, and can’t have a vulnerability in how it stores one, because it never touches it. The same reasoning applies to anything built on gh, aws, gcloud, or any other CLI that already has a real auth story.

The general form: when a credential is already being managed competently by something else, the most secure integration is the one that refuses to hold it.

The tokens I still have

I’d rather end this honestly than tidily, so here’s the current state rather than the aspirational one.

The estate still has stored, long-lived secrets. Two kinds, both real, neither of which I’ve found a clean way to remove.

An API key for a model provider, used by a handful of scheduled jobs and triage workflows. Nothing in that path currently offers OIDC-style federation for a repository-scoped identity, so the key stays until something does.

A personal access token for pushing to another repository of mine, which exists for a reason that’s worth understanding, because it’s the boundary made visible. GITHUB_TOKEN is scoped to the repository the job is running in. That’s exactly why it’s safe. The moment a workflow needs to reach a different repo, that scoping is precisely what stops it, and you need a credential that spans both. The ephemeral token’s greatest strength is the thing that forces you back to a stored one.

I’d also planned to write about Workload Identity Federation here, since federating from CI to a cloud provider is the natural next step and removes stored cloud keys the same way OIDC removes publish tokens. I went looking for it in my own infrastructure while drafting this and it isn’t there. Not implemented, not scheduled. I’m not going to write a section teaching something I haven’t actually run, so it’s a gap rather than a lesson, and it’s the next thing on this list.

That’s the honest inventory: one credential class genuinely eliminated, one design pattern that keeps tokens out of my own code, and two stored secrets I’d still have to rotate if they leaked tomorrow.

The shift underneath all of it

The reframe worth taking away is small and changes what you reach for.

Stop handing out keys. Start authenticating identities.

A key is a bearer credential: whoever holds it is you, forever, wherever they present it. An identity-based credential answers a different question, which is whether this specific workflow, in this specific repository, right now should be allowed to do this specific thing. It’s issued on demand, scoped to one job, and worthless the moment that job ends.

This is the same instinct as closing a management port instead of putting a better password behind it. You’re not making the thing harder to attack. You’re arranging for it not to be there.

Every stored token in your pipeline is a bet you re-accept silently every morning: that nobody has copied it, that its scope is still appropriate, that the person who created it thought carefully. Some of those bets you still have to make. I’ve got two.

The rest are just ones nobody has gotten around to not making.