Three weeks into running my publishing pipeline on autopilot, it just stopped. Not with a crash -- with the exact same error I'd already spent a week debugging and fixing once before: invalid_grant: Token has been expired or revoked. Same message, same script, same working setup. I assumed I'd broken my own fix. I hadn't. This was a completely different bug wearing the same disguise, and it had been sitting there from day one, counting down.
The first time I saw this error
The first time invalid_grant showed up, I'd just spent days chasing a silent scheduled-publish failure that turned out to be a PowerShell file encoding bug. Once that was fixed and confirmed working for a couple of runs, I filed the OAuth error away as "collateral damage from the encoding mess" and moved on. When it came back three weeks later, with the encoding fix still verified working, that theory stopped holding up.
My setup uses the OAuth installed-app flow: run the script once, a browser tab opens, I click through my own Google account's consent screen, and a refresh token gets cached to a local JSON file. Every run after that is supposed to be silent -- the script uses the refresh token to mint a fresh access token automatically, no browser, no human. That's what makes it usable for something a scheduled task fires at 9pm while nobody's watching.
Ruling things out
I checked the obvious suspects first. The Google account's security page showed no manual revocation, no "you signed out of this app" notice, nothing in the activity log around the time it broke. The token file itself was untouched -- same modification date as when it was first created. The client secret hadn't changed. The script hadn't changed. And critically, it had worked fine for roughly a week after the last real fix, then died on a schedule that didn't line up with anything I'd done.
That "roughly a week" detail turned out to be the whole answer, and I almost talked myself out of taking it seriously because it seemed too tidy to be a real pattern rather than a coincidence.
The setting I'd never touched
Every OAuth app in Google Cloud has a consent screen with a Publishing status, managed under what Google now calls the Google Auth Platform, in the Audience tab. There are two states: Testing and In production. I'd never touched this setting because nothing in the initial setup flow flagged it as something that mattered -- I clicked through the defaults to get my client secret and started writing code.
The default is Testing. And in Testing, Google caps refresh tokens at 7 days, regardless of how often the app actually uses them. It doesn't matter that my script was calling the API daily, keeping the token "warm" in any normal sense of the word -- Testing mode expires the token on a fixed clock, not on an inactivity timer. The publishing status isn't about code review or app quality; it's a literal switch that determines whether Google treats your app as a short-lived experiment or a real, ongoing thing.
| Publishing status | Refresh token lifetime | Who can authenticate |
|---|---|---|
| Testing (default) | Fixed 7 days, no matter how active | Only accounts added as test users |
| In production | Long-lived, standard OAuth behavior | Any Google account |
For a project with sensitive or restricted scopes, moving to In production can trigger Google's verification review process. Mine only touches Blogger and Search Console data under my own account, which falls under the kind of low-risk scope that doesn't require that review -- switching the status was a one-click "Publish app" action, not a submission-and-wait process.
The fix, and the question right after it
I re-authenticated once more to get a fresh token, then switched Publishing status to In production. That was the actual fix. Before doing it, the obvious question was whether this touches billing at all -- it doesn't. Publishing status is an identity/consent configuration on an OAuth client, completely separate from any Google Cloud API usage or pricing. Flipping it costs nothing and doesn't enable any paid product on its own.
Since making that change, the token has survived multiple scheduled runs well past the old 7-day mark without needing to be touched again.
The defensive layer I added anyway
Fixing the root cause didn't stop me from also making the failure mode less silent, because "the script just stops running with no notification" is a bad default regardless of what caused it this time. I updated the credential-loading function to catch a refresh failure explicitly instead of letting it propagate as an unhandled crash, and fall back to interactive re-auth:
from google.auth.exceptions import RefreshError
from google.auth.transport.requests import Request
def get_credentials():
creds = None
if TOKEN_FILE.exists():
creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), SCOPES)
if not creds or not creds.valid:
refreshed = False
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
refreshed = True
except RefreshError:
refreshed = False
if not refreshed:
flow = InstalledAppFlow.from_client_secrets_file(str(CLIENT_SECRET_FILE), SCOPES)
creds = flow.run_local_server(port=0)
TOKEN_FILE.write_text(creds.to_json(), encoding="utf-8")
return creds
This doesn't fix an expired-token problem by itself -- if a scheduled task with no browser hits the interactive fallback, it'll still fail, just with a clearer stack trace in the log instead of a bare invalid_grant. What it buys me is that the next time something in this pipeline breaks for a genuinely new reason, I'll be able to tell the difference between "token expired, needs a human to click through consent once" and something else, instead of staring at the same cryptic error and assuming I already fixed it.
What I'd check first next time
If I were setting up any personal OAuth automation script again, the Publishing status is now the very first thing I'd check, before writing a single line of the actual integration. It's easy to miss because nothing about the initial "create OAuth client, download client secret" flow calls it out as consequential, and a script that authenticates fine on day one gives no visual signal that a 7-day clock just started. The bug doesn't announce itself until the exact same failure you already fixed once shows back up, wearing the same error message, for a completely unrelated reason.
Top comments (0)