A static API token stored in GitHub Secrets is a breach that has not happened yet. The timer is already running. You do not know how long the fuse is, because a long-lived token’s fuse is however long you set it at mint time, which for most people is “never thought about it.”
At enterprise scale a leaked CI token is an incident: rotate it, audit the blast radius, file the postmortem. At solo scale it is the same product-ending event as any other breach. My CI can reach Doppler, which holds every secret every product uses. A token that lets it do that, sitting in a secret store, readable by any workflow, valid for months, is precisely the thing I do not want to own.
So the goal is to own zero of them. Not “rotate them on a schedule I will forget.” Zero. The mechanism that gets you there is OIDC, and the good news is that it is strictly better by default: less to store, less to leak, less to remember.
The mechanism, in one paragraph
Instead of storing a token, the CI job proves who it is at runtime and trades that proof for a token that expires in minutes. GitHub Actions can mint a short-lived OIDC token for a workflow run, signed by GitHub, carrying claims about which repo and which workflow asked for it. Doppler has a matching feature, Service Account Identities (a paid capability, which I will price out in the cost section), that validates such a token against a set of expected claims and hands back a short-lived Doppler session token in exchange. The job posts the GitHub token to POST https://api.doppler.com/v3/auth/oidc with an identity slug, gets back a session token good for ten minutes to an hour, uses it, and the run ends. Nothing durable is stored anywhere. (I skip Doppler’s off-the-shelf secrets-fetch-action, which only injects fetched secrets as environment variables; my workflows need the raw session token itself, to hand to doppler run and to Terraform as TF_VAR_doppler_token.)
Why a static service token is the wrong shape
Three properties make a long-lived token structurally bad, and they are the same three that pushed the GitHub side off personal access tokens in an earlier migration:
Lifetime. The token is valid until someone remembers to revoke it. Rotation is a manual ceremony with no calendar attached. The compromise window equals the lifetime, and the lifetime is effectively forever.
No identity. A Doppler service token has a label, not a workload-bound identity. When it is exercised, the audit log names the token, not the caller. A leak shows up as “that token did something,” with no provenance about which workflow in which repo actually made the call.
No revocation chokepoint per purpose. One shared token feeds many consumers. Revoking it kills all of them at once. Granular, per-purpose revocation means minting per-purpose tokens, which nobody does, so in practice you get one big token and an all-or-nothing kill switch.
OIDC fixes all three at once. The credential lives for minutes, it is bound to a named service-account identity with claims that pin it to a specific repo, and each logical purpose gets its own identity you can disable independently.
What actually runs on OIDC today
Here is the concrete state, not the aspiration.
A word on “per-product” first, because it runs through everything below. Lionshead is a set of small separate repos, one per product, rather than one monorepo. I wrote about that setup and why I chose it in the reconciler post. The practical effect here is that most of this machinery is replicated per repo: one identity, one Doppler config, one token scope each, so a compromise in one product cannot reach another.
Terraform plan and apply, every product, no stored token. Every terraform-plan (on PRs) and terraform-apply (on merge to main) authenticates to Doppler by OIDC. The caller declares permissions: id-token: write, passes a per-product identity slug (for this repo, LIONSHEAD_WEB_DOPPLER_TF_IDENTITY_SLUG, a GitHub variable), and the reusable workflow does the exchange. The slug is not a secret: it is a stable identifier that is worthless without a GitHub-signed token carrying the right claims, so it lives as a plain repo variable that Terraform itself manages.
Three named service accounts, not one shared token. The setup provisions ci-platform-services-tf (admin, OIDC, session TTL 3600s, one hour), ci-credential-sync (OIDC, session TTL 600s, ten minutes, the job that writes org secrets into per-product Doppler configs), and one transitional static-token account (covered below). Each has its own claims and its own independent off switch.
The Terraform-managed identity itself. The Doppler provider has a native doppler_service_account_identity resource with a config_oidc block (discovery_url, claims, ttl_seconds). The thing that grants OIDC access is itself declared in code and reviewed in a PR, not clicked into a dashboard. The one exception is the very first admin identity, which cannot create itself: that is a one-time manual step in the Doppler dashboard, which I wrote down as the single documented exception it is, so it is not a mystery to future me.
A fallback that fails loud, not silent. The Terraform callers explicitly pass doppler-token-secret-name: ''. The reusable used to default that input to DOPPLER_TOKEN, so if an identity slug ever resolved to empty (org variable deleted, repo forked, a run context that cannot see org variables) and a bare DOPPLER_TOKEN happened to exist in scope, it would quietly slide back onto the static path. Passing an empty string forces a loud OIDC failure instead of a silent downgrade to the thing I am trying to delete.
A guard that proves it stays gone. The old workplace DOPPLER_TOKEN GitHub org secret was deleted on 2026-06-09. A scheduled audit workflow (security-audit-doppler-token.yml) watches for its reintroduction, and post-deletion Terraform runs across three products came back green with zero deprecated-static-token warnings, which is the only actual evidence that every Terraform caller is really on OIDC. (If that sounds like a theme, it is the make-it-fail-on-purpose rule from the last post: the guard is only worth something because I watched the audit catch a planted token before I trusted it.)
What still holds a long-lived token
This is the part every “we adopted OIDC” post leaves out. I have not finished. Here is exactly what is still static, and why.
The per-product Terraform bridge. Per-product Terraform still needs a Doppler token for two things: its own Doppler provider block, and its Vercel-sync REST calls. That second one is worth explaining: those calls register a Doppler-to-Vercel integration, which is what pushes each product’s secrets out of Doppler and into that product’s Vercel project environment, so the deployed app actually has its config at runtime. Terraform sets that sync up through Vercel’s API, and the call authenticates with a Doppler token. An OIDC session token (TTL 3600s, one hour) would expire partway through a long apply, so one service account, ci-per-product-tf-bridge, keeps a long-lived token. The one mitigation that matters: that token lives only in Doppler’s own state and the per-product config it populates. It is never written to a GitHub org secret. It is explicitly labeled transitional; the follow-up is per-product OIDC identities that retire it.
The Vercel build wrap. The vercel-deploy jobs still wrap vercel build in doppler run using a per-product static secret (LIONSHEAD_WEB_DOPPLER_SERVICE_TOKEN_CI) to inject build-time secrets like the Sentry auth token. This one is a plain GitHub secret and it is next on the list.
Note syndication. The workflow that cross-posts these notes to dev.to, Hashnode, and Mastodon uses that same per-product static token to pull the app tokens out of Doppler. The workflow file literally carries a # Migrate to Doppler OIDC comment, because I wrote the honest TODO in the place I would actually see it.
The Terraform state-backend keys. The R2 credentials that read and write remote Terraform state (LIONSHEAD_WEB_TFSTATE_R2_*) are static repo secrets issued by platform-services. These are S3-style keys, not Doppler tokens, so they are a different migration on a different provider’s timeline, but they are static and I am not going to pretend otherwise by leaving them off the list.
So: the Terraform authorization path is done. The build, deploy, and syndication paths are not. Naming the gap is the point. A half-migrated system that thinks it is finished is worse than one that knows it is halfway.
What it costs
The exchange adds one HTTP call and a few seconds to each job. The provisioning is Terraform I write once and inherit everywhere. The GitHub side is free.
The Doppler side is not, and I want to be straight about it: Service Accounts and their OIDC identities are a paid feature. They start on Doppler’s Team plan, which is $21 per user per month as of this writing, so if you are on the free or Developer tier this specific path is not open to you without upgrading. In my case the marginal cost of the OIDC work was zero, because I was already on the Team plan before this migration for an unrelated reason: a different product had outgrown the free tier’s cap on secret syncs. That is luck, not a saving, and I would rather you hear the real number than a “no vendor to pay” line that only holds if you happen to be pre-upgraded.
Against that cost, what I stop owning is a set of long-lived credentials to my entire secret store, each one a standing invitation I have to remember to revoke. At solo scale I would rather have a mechanism that forgets on my behalf, every single run, than a discipline I have to sustain forever.
What’s next
Next in the CI/CD sub-series: path-gated test routing across a five-product portfolio. How one reusable workflow decides which tests to run based on what a PR actually touched, why the path gate is shared but the runner command is deliberately caller-owned, and what breaks when you get that split wrong.
답글 남기기