I opened the secrets list in my Cloudflare Workers dashboard late one evening last week. Between the Lab sites and the apps I maintain on my own, I could not say out loud how many Gemini keys I had, or where they lived. I assumed a search through the codebase would answer that. What the search returned were environment variable names — the values themselves were sitting somewhere behind a dashboard.
September arrived, and I could no longer leave that vagueness in place. Standard API keys, including the ones with restrictions attached, will stop being accepted during this month. The replacement is an auth key tied to a Google Cloud service account.
The first thing I want to say is that this is not a swap of one string for another. A string swap ends with a code search. A change of origin only ends with an inventory.
What changes is not the string, but how the key is issued
A standard key used to be self-contained. You created one in the console, put it in an environment variable, and that was the whole story. It did not much matter which project owned it. The convenience came with a cost: if the key leaked, everything that key could reach leaked with it.
An auth key is bound to a service account. Who may issue it, which project owns it, and which APIs it may call are decided on the IAM side rather than in your application configuration. Key management moves out of the app's convenience and into the project's permission design.
That is where the weight of this migration sits. Replacing a string is a job for grep. Changing how permission is held means laying out every location, issuer, and purpose side by side — otherwise you have no way of knowing what is finished and what is still outstanding.
There is a pleasant side too. Instead of distributing a fresh string to every environment on each rotation, you get to hand out narrowly scoped accounts per role. I used the occasion to separate the account that runs my article-generation batch from the one that serves inference to the published sites.
Three layers, counted separately
My first mistake was looking only at the codebase and concluding, confidently, that there were three places. There were keys in deploy settings and on my own machine that never appear in source at all. Splitting the search into these three layers is what finally made the whole picture visible.
| Layer | Where to look | Easy to miss |
|---|---|---|
| Code | Source, tests, samples, docs, notebooks | Copy-paste snippets in a README, throwaway verification scripts |
| Deploy settings | CI secrets, Workers and function environment variables, container arguments | Workflows that are disabled but still configured, preview environments |
| Local machine | Shell rc files, .env, editor settings, keychain | Values added for a test months ago and forgotten |
Chasing this by hand was less reliable than writing it down once as a script. I run the following and keep the output as a plain text file.
#!/usr/bin/env bash
# gemini-key-inventory.sh — find where the keys actually live, in three layers
set -uo pipefail
PATTERN='GEMINI_API_KEY|GOOGLE_API_KEY|generativelanguage\.googleapis\.com|AIza'
echo "===== Layer 1: code ====="
# Skip .git and node_modules; look for key names and hardcoded traces
grep -rInE "$PATTERN" . \
--exclude-dir={.git,node_modules,.next,dist,build,vendor} \
2>/dev/null | head -50
echo
echo "===== Layer 2: deploy settings ====="
# Cloudflare Workers (run this where wrangler.toml lives)
if command -v wrangler >/dev/null 2>&1; then
wrangler secret list 2>/dev/null || echo " (wrangler: not authenticated, or not applicable)"
fi
# GitHub Actions repository and environment secrets
if command -v gh >/dev/null 2>&1; then
gh secret list 2>/dev/null || echo " (gh: no permission, or not applicable)"
for env in $(gh api repos/:owner/:repo/environments --jq '.environments[].name' 2>/dev/null); do
echo " [environment] $env"
gh secret list --env "$env" 2>/dev/null
done
fi
echo
echo "===== Layer 3: local machine ====="
for f in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile" "$HOME/.zprofile"; do
[ -f "$f" ] && grep -nE "$PATTERN" "$f" 2>/dev/null | sed "s|^|${f}:|"
done
# Only two levels down for .env files; going deeper never ends
find . -maxdepth 3 -name '.env*' -not -path '*/node_modules/*' -print 2>/dev/null \
| while read -r f; do grep -lE "$PATTERN" "$f" 2>/dev/null; done
echo
echo "Inventory complete. Now annotate each line with who issued it — the count is not the point."That last line turned out to matter most for me. A machine can produce the list of locations, but only you can recall the issuer and the purpose. If a key surfaces that you cannot account for, it does not belong on the migration list. It belongs on the deletion list.
Put a dual-path client in the middle before you switch
What I find frightening about a deadline migration is the moment everything stops at once. So I put a small function in front of client creation: prefer the service account path, fall back to the legacy path if it fails, and — this is the part that matters — leave a warning in the log when it falls back.
# gemini_client.py — choose an auth path, and always record which one started
from __future__ import annotations
import logging
import os
from google import genai
log = logging.getLogger(__name__)
def build_client() -> tuple[genai.Client, str]:
project = os.getenv("GOOGLE_CLOUD_PROJECT")
location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
legacy_key = os.getenv("GEMINI_API_KEY")
if project:
try:
client = genai.Client(vertexai=True, project=project, location=location)
# Constructing a client does not verify auth. Make one real call.
next(iter(client.models.list()), None)
return client, "service-account"
except Exception as exc: # auth, permission, and network together
if not legacy_key:
raise
log.warning("Service account path unavailable: %s", exc)
if not legacy_key:
raise RuntimeError(
"Neither GOOGLE_CLOUD_PROJECT nor GEMINI_API_KEY is set"
)
log.warning("Started on the legacy API key path; migrate before the September cutoff")
return genai.Client(api_key=legacy_key), "legacy-api-key"
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
_, route = build_client()
log.info("gemini auth route=%s", route)The reason for next(iter(client.models.list()), None) is that constructing the client verifies nothing. Leave it out, and the failure stays hidden until your first inference request. One call at startup means you can read the answer straight off the deploy log.
From there, searching your logs for route=legacy-api-key tells you which environments are still on the old path. I emit that line from each service at startup and glance at it in the morning. As environments dropped off the list, I deleted their old keys one at a time.
There is a related trap where a duplicated environment variable name means your replacement value never takes effect. I wrote that one up separately in the note on why the old key wins after you swap it, and reading it before the inventory may save you some time narrowing down the cause.
Where I stumbled: permission granularity, and my own machine
After creating the service account, I started out granting a broad role. I wanted something working in front of me first. The risk is forgetting to come back: you end up handing out a key with wider reach than the standard key ever had. Once the calls succeeded, I narrowed the inference account down. Granting broadly first is fine, as long as the day you narrow it goes on the calendar in the same sitting — that is the line I drew this time.
Locally, I stopped short while downloading the service account JSON. My working folder sits inside a synced Dropbox directory, and putting a key file there would leave the key in sync history as well. In the end I keep no JSON on my machine and use ADC through gcloud auth application-default login instead. Deciding just that one thing — no key files on the local machine — takes a surprising amount of worry out of the migration.
For CI, Workload Identity federation is easier to live with than pasting a JSON into a secret. With the deadline at the end of this month, though, there is nothing wrong with a two-stage approach: get it working through secrets first, then move to federation. On a deadline, building something that does not stop matters more than building the correct shape in one pass.
How I ordered the remaining days
Having an order written down makes it easier to keep going when energy runs low. Mine looks like this.
- Run the inventory script and write the three-layer list to a text file
- Annotate each line with issuer and purpose; anything unaccounted for goes on the deletion list
- Add the dual-path client and emit
routeto the log - Switch the lowest-impact service to the service account first
- Delete old keys only for environments where
legacy-api-keyhas disappeared from the logs
Build the state in which a key can be deleted, then delete it — not the other way around. It reads as obvious, and yet a close deadline makes the reverse tempting.
A small note on the annotation step, since it is the one most likely to be skipped. When I wrote issuer and purpose next to each line, three entries came back with nothing beside them. Two were from a weekend experiment I never finished, and the third belonged to a preview environment that had been idle for months. None of them needed migrating. Deleting keys is usually the part I put off, and having them fall out of an inventory rather than a security review made it feel like ordinary housekeeping instead of an admission of something. If your list has similar orphans, they are the cheapest progress available to you today.
One more September date sits alongside this one: the gemini-omni-flash-preview endpoint retires at the end of the month. That one is mostly a model ID swap, so clearing the key migration first — the change with the wider blast radius — should make the end of the month considerably calmer.
For today, run the inventory script once and get as far as a text file listing where your keys live. That is the point where my own shoulders finally dropped.