The top row of the dashboard was red. invalid_grant: Token has been expired or revoked. The YouTube token for the ko channel was dead. Just reauth it, I figured. Then the next day en died, then ja, the same way. One per day, in order.
A question first. Is your OAuth app right now in "Testing" or "Production"? I had never once looked at that value.
First, the reauth hell
Before the root cause, there were two surface pains. Both are useful to someone else, so I'll write them first.
Gotcha 1 — PKCE verifier mismatch. It's a headless server, so the browser opens on a different machine. Building the auth URL saves a PKCE code_verifier to a file, and the code exchange must reuse that verifier. But the browser's address-bar autocomplete kept grabbing an old URL (old code_challenge). Regenerate the URL and the verifier file holds only the newest one — so a code from the old URL never matches. Every exchange: Invalid code verifier. The escape was to drop PKCE entirely.
# hand-built URL: no code_challenge
params = {"response_type":"code","client_id":cid,
"redirect_uri":"http://localhost:8765/","scope":scope,
"access_type":"offline","prompt":"consent"}
# exchange: no verifier, client_secret only
flow.code_verifier = None
flow.fetch_token(code=code)A desktop client exchanges on client_secret alone. A PKCE-free URL has no state, so it's reusable — an old tab grabbing it is harmless.
Gotcha 2 — six accounts with identical names. These six brand accounts all show up as just "OOTSSU" in the account chooser. No handle is visible, so you can't tell which is the target from the screen. Only one way: exchange first, then ask the channel.
h = build("youtube","v3",creds).channels().list(
part="snippet", mine=True).execute()["items"][0]["snippet"]["customUrl"]
# save to the real token file only on a handle match, else retry the next accountWorst case, six tries. I ran nearly six.
First principles: does this pretend to have a cheap fix?
The reauth worked. But I put my standard metric question on it — "does this label pretend a cheap fix exists?" invalid_grant looks like "reauth and done." That was the trap. Leave the root unfixed and it repeats every week.
My first suspect was "reauth/re-consent revokes the old token." I had re-consented all six accounts hunting for ko — did that kill en and ja? A counter-example surfaced. The three app-download channels (krapp, enapp, jaapp) I also re-consented in the same session were alive and well. If re-consent were the culprit, they'd be dead too. Re-consent was harmless.
That leaves the sequential-death pattern. ko → en → ja, a day apart. If each channel was last authorized at a different time, that means they expire in that order after a fixed lifetime. This is exactly Google OAuth's documented behavior. When the consent screen is in "Testing" publishing status, refresh tokens expire after 7 days.
Let me stop and ask. What would you do? Build an ops routine to reauth on each death — automating this chore every week? Or flip one publishing-status setting and remove the expiry itself?
The twist: publishing to production is not retroactive
The fix is one console change: Google Cloud Console → Auth Platform → Audience → publish the status from "Testing" to "Production." Even with sensitive scopes, under 100 users it goes through immediately, no review.
But here's the twist. Publishing to production does not apply retroactively. Tokens issued while in testing mode — including the ko/en/ja I just reauthed — will each die once more after their 7 days. Only tokens re-issued after the switch become unlimited. So one last round remains next week. Reauth then, and from that point it's stable.
The honest part
I can't claim the cause 100%. I didn't programmatically read the testing-mode expiry and prove it; I built the most-likely hypothesis from (1) the counter-example against the re-consent theory and (2) the sequential-death pattern. The real test is next week — if the production-reissued tokens survive past 7 days, the hypothesis held. If not, I trace again. Not trusting a metric by its label applies here too.
Three things to check in your own project:
- Is your OAuth app's publishing status Testing or Production? If Testing, every refresh token is a 7-day timebomb.
- When you see
invalid_grant, are you suspicious of the cheap fix — reauth and move on — versus asking why it expired at all? - If multiple credentials fail in sequence, that may be a signal of a shared expiry policy, not individual incidents. Don't respond one by one; look at the pattern.
Other credential gotchas in a headless setup are in the Toss Invest API notes, and the infra these bots run on is here.
Do one thing now. Check the publishing status of your OAuth consent screen in the Cloud Console. If it says "Testing," your tokens are quietly counting down too.