DEV Community

健太 橘
健太 橘

Posted on Originally published at mathman1111.github.io

Google's OAuth 'Testing' mode expires refresh tokens in 7 days. Publish the consent screen before you schedule anything.

TL;DR — If your Google Cloud OAuth consent screen is still in Testing, every refresh token it issues dies after seven days. That is documented behavior, not a bug. My unattended YouTube uploader ran fine for a week and then stopped at 06:45 on a Saturday. The fix is not "re-authenticate"; it is "publish the app", and publishing has prerequisites that took an hour, not a minute.


You scheduled it. It ran for a week. Then it stopped.

That is the whole shape of this failure, and if you have a cron job, a GitHub Action, or a Task Scheduler entry that talks to a Google API with a refresh token you minted on your laptop, it is probably sitting in your future too.

What happened

My YouTube Shorts pipeline is the one part of my setup that runs with nobody watching: Claude Code writes the script, a renderer makes the video, and a PowerShell runner uploads it at 14:00 and 21:00 every day. On September 12 at 06:45 the refresh token expired. It was exactly seven days old.

The same morning, before the first scheduled slot, I added a small script called check_youtube_token.js in front of the runner. It tries to refresh the token; if that fails, the runner writes SKIP: waiting for YouTube re-auth to its log and exits without launching anything. So the outage was loud rather than silent: three skipped slots that day, two more the next, five lost uploads in total, each with the reason next to it. The next morning's self-check surfaced the same line.

But a machine cannot click "Allow" on a Google sign-in page. The five slots stayed lost until a human sat down.

Why: this is Google's documented behavior

From the OAuth 2.0 docs, under refresh token expiration: a Google Cloud project whose OAuth consent screen is configured for an external user type and has a publishing status of Testing is issued a refresh token that expires in seven days.

Read that again if you built your integration the way most tutorials show. You create a project, set up the consent screen, add yourself as a test user, run the local auth flow once, save token.json, and schedule the job. Everything in that sequence leaves the app in Testing. The token works on day one. It works on day six. On day seven it does not, and nothing in the auth flow warned you.

Re-authenticating buys you another seven days and nothing else. If that is your fix, you will be doing it every week.

The fix: put the consent screen in production

The real fix is changing the publishing status from Testing to In production. In my case the "Publish app" button was greyed out, and here is why, in the order I hit it:

Google requires three branding fields before an external app can be published: an application homepage URL, a privacy policy URL, and at least one authorized domain. The authorized domain has to be one you have proven you own, and the proof Google accepts is Search Console verification. My domain was not verified yet, so the branding form would not save, so the publish button would not enable.

So the actual sequence was: open Search Console, add the domain as a property, verify it by uploading an HTML file to the site root, go back to the consent screen, fill in the three branding fields, save, click Publish app, then run the auth flow one more time so the new refresh token is issued under production status. The re-auth itself took about a minute. The rest took the better part of an hour, starting before 5 a.m., because the pipeline had already been down for two days.

After that, check_youtube_token.js reports the app as production with no expiry on the token, and the 14:00 slot that afternoon uploaded normally.

One thing to check before you assume you are safe: the token response tells you. When the app is in Testing, the JSON that comes back from the auth flow includes a refresh_token_expires_in field counting down from seven days. If you see that field, you are on the clock.

What changed in the runner

Two things, and neither is clever.

First, the token check runs before the expensive part. The runner used to launch Claude Code first and let the upload step, at the very end, discover the dead token. Now the check is the first thing that happens; if it fails, the run costs nothing and the log says exactly why.

Second, "skip with a reason" is treated as a first-class outcome, not a failure to hide. A row that says SKIP: waiting for YouTube re-auth is more useful than a row that says OK when nothing was uploaded, and a lot more useful than a stack trace at the bottom of a long log. The daily self-check reads those rows and puts the human task at the top of the to-do list.

If you are building anything unattended on Google APIs, the order is: verify the domain, fill in branding, publish the consent screen, then run the auth flow, then schedule. Doing it in the other order works for exactly one week.

My take

My take: Google should refuse to issue a refresh token to a Testing app at all, instead of letting it die silently on day seven. Tell me why I'm wrong — or tell me which of your scheduled jobs is still running on one.


A Japanese version of this post goes up the same day on my note.com blog. The runner with the pre-launch token check is free, pay what you want, on Gumroad — I own the store; that is a plain product link.

Top comments (0)