A value the browser must transmit is not a secret, however much it looks like one. Getting that boundary wrong wastes effort in one direction and leaks credentials in the other.
Every project I run has a different idea of where a secret lives. Firebase functions read from
Google Secret Manager. Cloudflare Workers read from bindings populated by wrangler secret
put. Local development reads from a gitignored .env. Continuous integration reads
from repository secrets. Four stores, four access patterns, one mental model needed — and the
mental model is the thing that actually prevents leaks, not any individual store.
The model I use has two rules. A secret is a value whose disclosure grants someone capability. And a value the browser must transmit is not a secret, no matter how much it looks like one. Almost every mistake I have made or reviewed in this area comes from getting the second one wrong in one direction or the other.
This is the part that is genuinely counter-intuitive, and treating it as a matter of caution rather than of fact produces bad engineering in both directions.
A Firebase web API key looks exactly like a credential. It is a long opaque string beginning
AIzaSy, it is called a key, and it sits in the client bundle where anyone can read it. The
instinct to hide it is strong and completely unfounded: the browser has to send it on every request,
so it is unavoidably public, and moving it into an environment variable that gets inlined into the
same bundle at build time achieves precisely nothing. It is not a secret. It is a
project identifier, and it authorises nothing on its own.
The same is true of a reCAPTCHA or Turnstile site key, a Stripe publishable key, and an
analytics beacon token. Each has a matching value that is secret — the reCAPTCHA secret
key, the Stripe sk_ key — and the entire security model rests on keeping those two
things straight.
What actually protects a public key is not concealment, because concealment is impossible. It is:
I put this in writing on projects because the failure mode is social rather than technical: somebody sees a long key in a public bundle, raises it as a security incident, and the fix that gets applied is a false one that consumes a day and leaves the real gap — the missing referrer restriction, the unverified token — untouched.
The working list, and it is short: service account JSON, anything beginning sk_live_
or sk_test_ or whsec_, SMTP passwords, OAuth client secrets, session signing
keys, reCAPTCHA and Turnstile secret keys, and any private key of any kind. Signing material
for mobile builds — keystores, .p8 and .p12 files — belongs in
the same category and, additionally, should not live inside a git working tree at all, because the
most common way it gets committed is a wildcard git add from a directory nobody thought
about.
The test for anything ambiguous: if a stranger had this string, what could they do? If the answer is "identify the project", it is public. If the answer is "act as the project", it is secret.
The stores differ mechanically and it is worth knowing how, because the differences determine what "rotate this" actually costs.
Google Secret Manager, which Firebase functions bind to, is versioned. You add a version, and the function picks it up on next deploy. Old versions remain accessible until you disable or destroy them, which is a feature during a rotation and a liability afterwards if you forget to destroy the one that leaked. Access is IAM-controlled, which means it is auditable and can be scoped per service account rather than per project.
Cloudflare Workers secrets are write-only from the outside. Once
wrangler secret put has stored a value there is no command that reads it back; the Worker
sees it as a binding at runtime and nothing else does. That is a genuinely better default than an
environment variable, and it has one practical consequence: the value must exist somewhere else too,
because you will eventually need to know what it was, and "read it back from production" is not
available.
Repository secrets in CI are the highest-risk store, because CI runs arbitrary code from pull requests and its whole job is to have credentials. Scope them narrowly — a deploy token that can deploy and do nothing else — and treat any workflow that runs untrusted code as a workflow that must not see them.
The local .env is the one that leaks. Not because it is badly designed
but because it lives in the working directory, and the working directory is what gets committed. Three
things make it safe, and all three have to be true: .gitignore covering
.env, .env.* and *.env; a committed
.env.example carrying key names with empty values so a new checkout knows what to
fill in without anything sensitive being present; and a pre-commit secret scan, because
.gitignore only protects files you remembered to name.
Everything above is prevention, and prevention has a failure rate above zero. The question that determines how bad a leak is turns out to be a boring operational one: how long does it take to replace this value, and does anything break while you do?
A credential you can rotate in ten minutes turns a serious incident into an annoying afternoon. A credential that is hardcoded in three deployed services, referenced by a name nobody remembers, and shared between two products turns the same leak into a week of archaeology conducted under pressure. The difference is not how well the secret was hidden. It is entirely a matter of how it was wired.
The wiring that makes rotation cheap:
A secret committed and then removed in the next commit is still in the repository, still in every clone, and still in the fork somebody made. Removing it from the working tree changes nothing about its availability. Once a value has been pushed, it is compromised, and the only real remediation is rotation — history rewriting is cleanup, not a fix, and it does not reach the clones.
Which is why the controls that matter are the ones that operate before the push: a pre-commit scan that blocks the commit, and provider-side push protection that rejects the push. Both are free, both take minutes to enable, and both are worth more than any amount of care, because care is exactly what fails at the end of a long session.
The mistake I keep seeing — including in my own earlier work — is treating this as a question of storage. It is not. Where a value sits is the least interesting property it has. What matters is what it authorises, whether that authority is scoped to one job, how fast it can be replaced, and whether anything in the pipeline copies it somewhere you were not thinking about.
A secret in the right store with unlimited scope and no rotation path is worse than an over-cautiously-handled public key, because the first one will eventually hurt and the second one was never going to.
Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.