◉GEMINI LABJP
●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — The old Deep Research agent shut down on 10/23. If calls fail, move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●CLI — Gemini API CLI (gemini-api) is out in beta (10/27): a zero-dependency client for agents and CI●STORE — New paid projects now save Generate Content requests by default (store on) as of 10/26●NEW — Stop Re-Reading Every Crash Report: Gre…●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — The old Deep Research agent shut down on 10/23. If calls fail, move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●CLI — Gemini API CLI (gemini-api) is out in beta (10/27): a zero-dependency client for agents and CI●STORE — New paid projects now save Generate Content requests by default (store on) as of 10/26●NEW — Stop Re-Reading Every Crash Report: Gre…
Articles/API / SDK
◈ API / SDK/2026-10-30Beginner

An 'API key expired' Error Doesn't Always Mean Expired. Check Which Key Is Actually Sent Before You Renew It

A 400 with API_KEY_INVALID from the Gemini API cannot tell you whether the key expired or something else is wrong. A small diagnostic script checks env-var precedence and stray quotes first, run here against dummy keys.

Gemini API254API key4error handling3google-genai8troubleshooting97

You issued a fresh API key, and the same error comes back. You recreated the key, recreated the project, checked billing, and API key expired. Please renew the API key. is still there.

There is an issue like that in the gemini-cli repository on GitHub, opened in April 2026. Running an agent through ADK failed every time with this error. A maintainer replied that the key had probably expired, and the reporter answered that a brand-new key in a brand-new project behaved the same. About a week later the reporter wrote "resolved for me" and closed it. The cause was never written down.

I can't recover a cause nobody recorded, so I checked what I could check by hand. I called the real endpoint with dummy keys, and I read the google-genai SDK source (version 2.33.0) to follow where a key comes from and how it gets sent. The short version: I found no way to read "expired" out of the response. What I did end up with is a one-minute check to run before you renew anything.

The response doesn't separate "expired" from "just not a valid key"

The response pasted in the issue was an HTTP 400. Not 401, not 403, but INVALID_ARGUMENT, with reason: API_KEY_INVALID inside the ErrorInfo detail.

The same shape came back for a made-up dummy key. Only the message changed, to API key not valid. Please pass a valid API key. I varied the key's form and sent hi to gemini-3.8-flash:

Key sentResult
Made-up key, as is400 INVALID_ARGUMENT, reason API_KEY_INVALID
Leading/trailing spaceSame
Trailing newline (LF, CRLF)Same
Wrapped in quotesSame
Three charactersSame
Empty string or unsetValueError locally, before any request

reason only says "this key is not accepted." Expiry, revocation, a truncated copy, and a different key slipping in all look identical here. Don't try to guess the cause from the response; rule out what you can see on your own machine first. That is the order I settled on.

If you decide "it expired" and regenerate, a cause that lives on your side will reject the new key in exactly the same way.

When a new key doesn't help: two environment variables

The SDK source (_api_client.py) has a function that reads the key from the environment. Its docstring says it prioritizes GOOGLE_API_KEY, so when both variables are set, GEMINI_API_KEY is never read.

I tested it with two dummy keys: one run with the new key only in GEMINI_API_KEY, and one with the old key still sitting in GOOGLE_API_KEY. The first used the new key; the second used the old one. In the second case a single warning line appears, Both GOOGLE_API_KEY and GEMINI_API_KEY are set. Using GOOGLE_API_KEY., but it is one WARNING among everything else a server or an ADK run prints, and I can see it slipping past.

As an indie developer, I tend to leave keys in both my shell profile and each project's .env. In that setup the older one wins. You can paste a new key into .env and still send the stale GOOGLE_API_KEY from the shell.

I can't say whether the issue's reporter hit this, since the cause was never written down. But as a candidate for "a new key doesn't fix it," it is the first thing worth ruling out. I understand ADK to use google-genai underneath, though I did not run ADK itself for this. A companion piece written the same day approaches the same error from the ADK side (My ADK Agent Never Ran and Said 'API key expired' — Reading the Reason Code Gave Me a Triage Order); that one works from the official pages and the issue, while this one rests on runs I did by hand.

Whitespace, newlines, quotes: what the SDK fixes for you

Client(api_key="AIzaNL\n") held 'AIzaNL'. The source has the line self.api_key = self.api_key.strip(). Leading and trailing whitespace is not a cause with this SDK version.

Quotes are not removed, though. Depending on how your .env is loaded, KEY="AIza..." can arrive with the quotes inside the value. And the stripping is specific to this SDK at this version; if you hand the same value to curl or a client in another language, you have to check that yourself.

A diagnostic script: compare fingerprints, never print the key

This is a script of about 70 lines to run before you regenerate a key. It never prints the key; it shows the first 8 hex digits of a SHA-256 hash. It lines up the two environment variables and the key actually used, then calls models.list for a single item to reach a verdict. models.list involves no generation, so a check costs no tokens.

"""Gemini API key check: see which key is actually sent before you renew anything."""
import hashlib
import os
import sys
 
from google import genai
from google.genai import errors
 
 
def fingerprint(key: str | None) -> str:
    """Never print the key itself; compare identity by an 8-char hash prefix."""
    if not key:
        return "(not set)"
    return "sha256:" + hashlib.sha256(key.encode()).hexdigest()[:8]
 
 
def shape_problems(key: str) -> list[str]:
    """Problems visible before any request, including ones the SDK quietly fixes."""
    problems = []
    if key != key.strip():
        problems.append("leading/trailing whitespace or newline (this SDK strips it; other clients may not)")
    if key.strip().startswith(('"', "'")) or key.strip().endswith(('"', "'")):
        problems.append("quote characters left in the value (depends on how your .env is loaded)")
    if not key.isascii():
        problems.append("non-ASCII characters (full-width or invisible characters from copy/paste)")
    return problems
 
 
def reason_of(e: errors.APIError) -> str | None:
    """e.details is the whole response body (a dict), not the inner details list."""
    body = e.details if isinstance(e.details, dict) else {}
    for d in body.get("error", {}).get("details", []):
        if d.get("@type", "").endswith("google.rpc.ErrorInfo"):
            return d.get("reason")
    return None
 
 
def classify(e: errors.APIError) -> str:
    reason = reason_of(e)
    if reason == "API_KEY_INVALID":
        return "key"        # do not retry; suspect the key being sent
    if e.code == 429:
        return "quota"      # wait, or revisit limits
    if e.code >= 500:
        return "transient"  # safe to retry with backoff
    return "other"          # other 4xx: fix the request itself
 
 
def main() -> int:
    google_k, gemini_k = os.environ.get("GOOGLE_API_KEY"), os.environ.get("GEMINI_API_KEY")
    print(f"GOOGLE_API_KEY : {fingerprint(google_k)}")
    print(f"GEMINI_API_KEY : {fingerprint(gemini_k)}")
    if google_k and gemini_k and google_k != gemini_k:
        print("-> The two variables differ. The SDK uses GOOGLE_API_KEY.")
 
    client = genai.Client()  # no arguments = same resolution order as production
    print(f"Key actually used: {fingerprint(client._api_client.api_key)}")
    for name, raw in (("GOOGLE_API_KEY", google_k), ("GEMINI_API_KEY", gemini_k)):
        for p in shape_problems(raw or "x"):
            print(f"Shape problem ({name}):", p)
 
    try:
        next(iter(client.models.list(config={"page_size": 1})))  # no generation involved
    except errors.APIError as e:
        print(f"Verdict: {classify(e)} / HTTP {e.code} {e.status} / reason={reason_of(e)}")
        return 1
    print("Verdict: ok (models.list succeeded with this key)")
    return 0
 
 
if __name__ == "__main__":
    sys.exit(main())

Here is what it printed with dummy keys. First, the old key left in GOOGLE_API_KEY while the new one went into GEMINI_API_KEY:

GOOGLE_API_KEY : sha256:40b5137b
GEMINI_API_KEY : sha256:2725a3cf
-> The two variables differ. The SDK uses GOOGLE_API_KEY.
Key actually used: sha256:40b5137b
Verdict: key / HTTP 400 INVALID_ARGUMENT / reason=API_KEY_INVALID

The fingerprint of "Key actually used" matches GOOGLE_API_KEY. You thought you had switched keys, and this shows the old one is still the one being sent.

Next, a trailing space left in GEMINI_API_KEY:

GOOGLE_API_KEY : (not set)
GEMINI_API_KEY : sha256:7d3158e4
Key actually used: sha256:a6d9ddc3
Shape problem (GEMINI_API_KEY): leading/trailing whitespace or newline (this SDK strips it; other clients may not)
Verdict: key / HTTP 400 INVALID_ARGUMENT / reason=API_KEY_INVALID

The environment variable's fingerprint and the "actually used" fingerprint differ. That means the SDK repaired the value locally before sending it, and the gap itself is the signal that something extra is attached to the value.

Branch on reason, not on the HTTP status

Two traps for when you wire this into error handling.

The first is what e.details holds. The name suggests a list of details, but it is the whole response body as a dict, with a single top-level key, error. Writing e.details[0] never reaches the ErrorInfo you want. Walk error, then details, then ErrorInfo, as reason_of does.

The second is the retry decision. A 400 is usually "your request is wrong," so leaning toward no retry is sensible, but API_KEY_INVALID is about the key being sent, not the request body. In an unattended job such as a nightly batch, splitting on reason lets you route a revoked key to "stop and notify." I wrote about splitting retryable failures in The Nightly Batch Sat Stalled Until Morning — Splitting Retryable Failures by gemini-api Exit Code and Classify Gemini API Errors by Status — Handling 429, SAFETY, and Token Limits in Production. For the other "check it the day you notice" kind of setting, see New paid projects now start with request storage switched on. Three places to check the day you notice.

What I haven't verified

Since I'm writing this down, I should say where it stops.

  • A successful call with a valid key. I don't keep a live key on this machine, so the ok branch has never run. It is my reading of the code.
  • The response for a genuinely expired key. I couldn't reproduce it. The word "expired" above is quoted from the issue.
  • SDK version. I looked only at 2.33.0. Precedence and stripping may change between versions.
  • Anything through ADK. I did not run it.

The next time I see this error, I'll run keycheck.py before I renew the key. If the fingerprints disagree, the thing to fix is the environment, not the key.

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 $10 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

◈ API / SDK2026-10-30
My ADK Agent Never Ran and Said 'API key expired' — Reading the Reason Code Gave Me a Triage Order
When ADK returns 400 INVALID_ARGUMENT 'API key expired. Please renew the API key.' on every run, check this order before assuming the key expired: read the reason code, compare shell and .env keys by their last four characters, then test the key alone over REST.
◈ API / SDK2026-10-14
When Your config Raises "Extra inputs are not permitted", Check the Spelling of Every Key
If a google-genai call fails with a pydantic ValidationError (extra_forbidden) the moment you pass a config, it is almost always a one-letter typo. Here is the output I reproduced, a small function that checks keys before the call, and how to tell why response.text came back as None.
◈ API / SDK2026-08-15
generate_images Survives Until 2027. From 2.20.0 On, It Stops on Your Machine First
google-genai still ships generate_images, but from 2.20.0 a Gemini Developer API client raises ValueError before any request is sent. I measured three separate stops on 2.18.1, 2.19.0 and 2.33.0: the SDK deadline (2027), the model deadline (August 17) and the one that happens locally.