◉GEMINI LABJP
●TTS — Gemini 3.8 Flash TTS and Flash-Lite TTS are GA as of September 22. Voice replication works on Flash only; Flash-Lite returns a 400●2.5 — Access to the 2.5 models is now limited to accounts with prior active usage (September 18). Not a deprecation: new projects should start on 3.5 Flash-Lite or 3.8 Flash●9/30 — Two days until gemini-omni-flash-preview shuts down, and gemini-2.5-flash-image follows on October 2. In practice the replacement for the latter is gemini-3.1-flash-image●AUTH — Reports continue of Gemini CLI sign-in failing only for Workspace Enterprise accounts while personal accounts work. Isolating the two is the current focus●NEW — Moving to 3.8 Flash-Lite TTS meant re-picking the voice for our guidance audio●429 — A brand-new project shows Free tier yet returns 429 with limit: 0. Here is what to check in the first hour●TTS — Gemini 3.8 Flash TTS and Flash-Lite TTS are GA as of September 22. Voice replication works on Flash only; Flash-Lite returns a 400●2.5 — Access to the 2.5 models is now limited to accounts with prior active usage (September 18). Not a deprecation: new projects should start on 3.5 Flash-Lite or 3.8 Flash●9/30 — Two days until gemini-omni-flash-preview shuts down, and gemini-2.5-flash-image follows on October 2. In practice the replacement for the latter is gemini-3.1-flash-image●AUTH — Reports continue of Gemini CLI sign-in failing only for Workspace Enterprise accounts while personal accounts work. Isolating the two is the current focus●NEW — Moving to 3.8 Flash-Lite TTS meant re-picking the voice for our guidance audio●429 — A brand-new project shows Free tier yet returns 429 with limit: 0. Here is what to check in the first hour
Articles/Dev Tools
⟐ Dev Tools/2026-09-29Intermediate

Gemini CLI Kept Saying 'API key not valid' After I Gave It a New Key — It Was Reading an Old One From a Third Place

I switched Gemini CLI from Google login to a paid API key, and the 400 'API key not valid' error would not go away. The key can come from four places — the shell, a project .env, a home .env, and settings.json — and a short script that lights them up in order narrows the cause to one.

gemini-cli6api-key4authentication5troubleshooting84envsettings-jsonheadless2

The night I moved a small site-check script for my Lab sites from the free Google-login quota to a paid API key, I did what the docs said. I created a key in AI Studio, exported it in the terminal, and ran gemini -p. What came back was a line I had never seen before the switch: API key not valid. Please pass a valid API key.

The key was minutes old. I had checked the copy. The same line came back three times, and I decided the key itself was broken, so I made another one in AI Studio. That didn't help either. I was halfway through creating a fourth when my hands stopped, and I finally asked the question I should have asked first — which key is the CLI actually reading?

Let me put the conclusion up front. Gemini CLI can pick up GEMINI_API_KEY from more than one place, and on my machine there were four. I had put the new key in the first place. The CLI was reading the third — a key I had left there half a year earlier and had since deleted.

Four places, and which one wins

Here is what I confirmed on my own setup, in the order the CLI resolves them.

#WhereWhat it doesMy situation
1Shell environment (`export`, `.zshrc`)If a value is already set, later .env files do not overwrite itNew key went here
2`.gemini/.env` or `.env`, searched from the working directory upwardOnly the first file found is readNone present
3`~/.gemini/.env` (falling back to `~/.env`)Used when nothing was found in step 2Old key was still here
4Auth method in `~/.gemini/settings.json`Decides whether a key or Google login is used at allAlready switched

On startup the CLI already holds whatever the shell gave it (1), then looks for .env files in the order 2 → 3 and fills in only the names that are still empty. So "I exported the key and it still uses the old one" should be rare in an interactive terminal.

It happened to me because the script ran from cron. A non-interactive cron shell does not read .zshrc. Place 1 was empty, place 2 didn't exist, place 3 held the old key. The CLI worked correctly, sent the old key correctly, and the API returned a correct 400. Nothing was broken except my memory.

One more note on place 4. The chosen method is stored under security.auth.selectedType as gemini-api-key, oauth-personal, or vertex-ai (older builds used a top-level selectedAuthType). If it is still oauth-personal, the key is never read at all — and the symptom is a free-tier quota message, not "API key not valid". The shape of the error tells you where to look; I only noticed that afterwards.

A script that lights up every source at once

This is the check I wrote that night and still keep in ~/bin. The problem it solves is simple: answer "which key would the CLI read?" by enumeration rather than guesswork. It prints only the first six characters and the length of each key, so nothing sensitive stays on screen.

#!/usr/bin/env bash
# where-is-my-gemini-key.sh — walk every place GEMINI_API_KEY could come from
mask() { local v="$1"; [ -z "$v" ] && { echo "(unset)"; return; }; printf '%s… (%d chars)\n' "${v:0:6}" "${#v}"; }
read_env() { grep '^GEMINI_API_KEY=' "$1" | head -1 | cut -d= -f2- | tr -d '"'"'"' | tr -d '\r'; }
 
echo "[1] shell env      : $(mask "${GEMINI_API_KEY:-}")"
 
d="$PWD"; hit=""
while :; do
  for f in "$d/.gemini/.env" "$d/.env"; do
    if [ -f "$f" ] && grep -q '^GEMINI_API_KEY=' "$f"; then hit="$f"; break 2; fi
  done
  [ "$d" = "/" ] && break
  d="$(dirname "$d")"
done
[ -n "$hit" ] && echo "[2] project side   : $hit → $(mask "$(read_env "$hit")")" || echo "[2] project side   : none"
 
for f in "$HOME/.gemini/.env" "$HOME/.env"; do
  [ -f "$f" ] && grep -q '^GEMINI_API_KEY=' "$f" && echo "[3] home side      : $f → $(mask "$(read_env "$f")")"
done
 
echo "[4] settings.json  : $(python3 - <<'PY'
import json, os
p = os.path.expanduser('~/.gemini/settings.json')
try:
    s = json.load(open(p))
except Exception as e:
    print('unreadable:', e); raise SystemExit
print(s.get('security', {}).get('auth', {}).get('selectedType') or s.get('selectedAuthType') or '(not selected)')
PY
)"
 
# [5] Hit the API with the key in hand, bypassing the CLI entirely
code=$(curl -s -o /dev/null -w '%{http_code}' -H "x-goog-api-key: ${GEMINI_API_KEY:-}" \
  "https://generativelanguage.googleapis.com/v1beta/models")
echo "[5] key by itself  : HTTP $code (200=valid / 400=invalid key / 403=restricted or API disabled)"

Three decisions are worth explaining. First, step [2] stops at the first file it finds, because that is how the CLI searches too — a second .env higher up the tree is never read. Second, tr -d '\r' is there on purpose: a .env edited on Windows can carry a carriage return at the end of the line, which appends an invisible character to the key and produces the same 400. I fell into that one on a different day. Third, step [5] does not go through the CLI at all. That single line separates "the key is bad" from "the CLI is reading a different key".

My output that night was [1] unset, [3] old key, [5] 400. Seeing [1] empty was the moment I accepted what a cron environment actually looks like.

Three other roads to the same line

Since then I've met the same "API key not valid" for three different reasons. The script above distinguishes all of them.

The first is putting a Vertex AI express-mode key into GEMINI_API_KEY. That key belongs to Vertex, and the generativelanguage endpoint treats it as invalid. To use it you set GOOGLE_API_KEY instead, add GOOGLE_GENAI_USE_VERTEXAI=true, and switch the auth method to vertex-ai. If [5] says 400 and you don't remember creating the key in AI Studio, look here first.

The second is regenerating a key in the Cloud console, deleting the old one, and forgetting the .env. Same shape as my night: [3] holds the old key and [5] returns 400. Rotation felt finished once the new key was in place, but there was one more place where the old one had to be removed.

The third is a stray newline or space before the closing quote in export GEMINI_API_KEY="…" inside .zshrc. If [1] reports one character more than an AI Studio key (39 characters, starting with AIza), that is the answer. You can't see it, so I've made a habit of trusting the count instead of my eyes.

When [5] returns 403, on the other hand, the key is real. Either the Cloud console has API restrictions on it, or the Generative Language API is disabled for that project — the fix lives outside the CLI. Back when I filed 400 and 403 under the same "auth error" label, this fork cost me a detour every time.

The line I drew

That night changed one rule for me. Keep the key in exactly one place, and treat the other three as things to verify are empty. Concretely, ~/.gemini/.env is now the only home for the key; I no longer keep a shell export or a project-level .env. Whether a run starts from cron or from a terminal, the same single file is read. Instead of memorising the search order, I chose a layout where the order doesn't matter.

For unattended runs I add one more thing: GEMINI_DEFAULT_AUTH_TYPE=gemini-api-key in the cron environment, so the auth method no longer depends on whatever state settings.json happens to be in. Even if someone switches back to Google login through /auth during the day, the night job is unaffected.

I applied the same idea to which MCP servers stay enabled — pulling the setting into one place — and wrote that up in The Day I Unplugged an MCP Server for the First Time — How I Decide Which Tools Stay On. When configuration is scattered, the tool keeps working correctly and I'm the one who gets it wrong.

For the next time you rotate a key

Before swapping keys, run the script once and confirm that [1] through [3] are either empty or hold a single old key. After swapping, wait until [5] shows 200 before going back to the CLI. That is the whole procedure, and it turned a night of three wasted keys into a single pass.

The same tug-of-war between several sources of one variable name happens in SDK code, not just in the CLI. I documented a case where reordering the loads changed nothing, and how I ended up building a ledger from the supply side, in Your New Gemini API Key Never Took Effect, and Load Order Wasn't the Reason, with working code. If you've cleaned up the four places in the CLI and want to line up the three paths in your code too, I hope it's useful.

Start by running cat ~/.gemini/.env and looking at what is still in there. As an indie developer keeping a handful of Lab sites running unattended, that is where I found a six-months-younger version of myself.

Share

Thank You for Reading

Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Dev Tools2026-04-09
Gemini Code Assist Outline Stays Empty: Seven Things to Check
Fix Gemini Code Assist Outline not showing suggestions, failing silently, or throwing authentication errors. Full coverage for VS Code and JetBrains—from plugin setup to enterprise IAM configuration.
⟐ Dev Tools2026-09-15
The approval rules I had written never matched once — auditing my Gemini CLI policies
A deny rule I trusted for half a year had never matched. The pattern was tested against a JSON string, the folder I kept my rules in is not read, and ask_user turns into deny the moment nobody is watching. Here is the audit, and the script I wrote to keep doing it.
⟐ Dev Tools2026-05-23
LM Studio 'Failed to Load Model' for Gemma 4 MLX — A 4-Bucket Diagnostic for Apple Silicon
When LM Studio refuses to load mlx-community/gemma-4-26b-a4b-it-4bit with a red 'Failed to load model' dialog, the cause is almost always one of four buckets. Here's how to triage them on an Apple Silicon Mac in under thirty minutes.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links