Install
$ agentstack add mcp-mankhonggarden-google-oauth-nonexpiring-refresh-token ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Google OAuth refresh tokens that don't expire every 7 days — without completing verification
> TL;DR — If your single-user Google integration (a Gmail/Calendar/Drive script, a personal MCP server, a home automation, a one-off internal tool) keeps dying with invalid_grant: Token has been expired or revoked roughly once a week, the cause is almost never your code. It's your OAuth app's publishing status. Flip it from Testing to In production and the refresh token stops expiring — even while the app stays "unverified." You do not have to pass Google's verification review to get a long-lived token.
This is the part most guides get wrong: they conflate "In production" (a publishing-status toggle you control instantly) with "verified" (a review process that can take weeks). They're two different axes. Token longevity is governed by the first, not the second.
The symptom
You built something that talks to a Google API with an OAuth refresh token. It works. Then ~7 days later, every call fails:
400 invalid_grant
{ "error": "invalid_grant", "error_description": "Token has been expired or revoked." }
You re-run the consent flow, get a fresh token, and exactly a week later it breaks again. You start writing a cron job to re-auth weekly. Don't.
Why it happens
Google issues short-lived (7-day) refresh tokens when your OAuth app's publishing status is Testing — unless the only scopes you request are a subset of the basic name / email / profile set. The moment you request anything beyond that — a sensitive scope (e.g. calendar) or a restricted scope (e.g. gmail.modify, gmail.readonly, Drive full access) — a Testing app's refresh tokens are capped at 7 days.
This is documented behavior. The trap is the next inference everyone makes:
> "So to get a non-expiring token for a restricted scope, I have to complete verification."
No. You have to move the app to In production publishing status. Verification is a separate thing.
The two axes people conflate
| Axis | What it is | What it controls | How long it takes | |---|---|---|---| | Publishing status (Testing ↔ In production) | A toggle on your OAuth consent screen you flip yourself | Refresh-token lifetime (7-day vs non-expiring), test-user allowlist requirement | Instant | | Verification (unverified → submitted → verified) | Google's manual review of your app + scopes (privacy policy, domain ownership, sometimes a security assessment for restricted scopes) | The "Google hasn't verified this app" warning screen, the user cap, eligibility to serve restricted scopes to strangers | Days to weeks (restricted scopes can require a paid third-party security assessment) |
The key realization: an app can be In production AND unverified at the same time. That combination is valid, and per Google's documented behavior it issues non-expiring refresh tokens for sensitive and restricted scopes.
What you trade for skipping verification is not token longevity. It's:
- Users see an "Google hasn't verified this app" interstitial (you click Advanced → Go to {app} (unsafe) to proceed).
- A user cap applies (on the order of 100 users for unverified apps).
- You can only let in users you control (yourself + anyone you add).
For a single-user or self-hosted integration — which is exactly the case for personal automations and most MCP servers — none of those three costs matter. You are the only user, you don't care about your own warning screen, and you're nowhere near the cap.
The fix (≈2 minutes)
- Open Google Cloud Console → APIs & Services → OAuth consent screen (newer console: Google Auth Platform → Audience).
- Find Publishing status. If it says Testing, click "Publish app" / "Push to production".
- Confirm. Status now reads In production. You can ignore the verification prompt — leaving it unverified is fine for a self/single-user app.
- Re-run your OAuth consent flow once to mint a fresh token under the new status. (Tokens minted while the app was in
Testingkeep their 7-day clock — the status change does not retroactively extend an already-issued token.) - Inspect the token response. You're looking for a refresh token with no expiry — see the next section for the exact field.
Confirming it on your own app
After publishing to production and re-running consent, inspect the token-exchange response. The single field that tells you everything is refresh_token_expires_in:
// Testing status, restricted scope — short-lived:
{ "access_token": "...", "refresh_token": "...", "expires_in": 3599,
"refresh_token_expires_in": 604800 } // 7 days, in seconds
// In production status (even unverified) — non-expiring:
{ "access_token": "...", "refresh_token": "...", "expires_in": 3599 }
// ^ no refresh_token_expires_in field → token does not expire on a timer
refresh_token_expires_in: 604800→ still on the 7-day clock (you're likely still inTesting, or re-using a token minted before the switch).refresh_token_expires_in: 0, or the field absent entirely → non-expiring.
That field flips based on publishing status, independent of verification state, and independent of which sensitive/restricted scope you requested. expires_in: 3599 refers to the short-lived access token — that one always rotates hourly and is not the thing you're tracking here.
Things that will still revoke a non-expiring token
"Non-expiring" means no time-based expiry. It does not mean indestructible. A long-lived refresh token still dies if:
- The user revokes access in their Google Account security settings.
- Six months of inactivity pass with the token unused.
- You change the requested scopes (the old grant no longer matches).
- You exceed the per-user/per-client live-token limit (Google silently revokes the oldest when you mint too many; ~50 live tokens per user/client is the classic ceiling — stop re-authing in a loop).
- A password reset / security event invalidates outstanding grants.
- You revert the app back to
Testing.
If you start seeing invalid_grant again after this fix, it's one of the above — not the 7-day timer.
When this is NOT the right answer
This recipe is for apps you control the user base of — single-user, self-hosted, internal, or a small allowlist. If you are shipping a public app where strangers sign in:
- The unverified warning screen and user cap do matter → you need to actually complete verification.
- Restricted scopes for a public audience may require a CASA security assessment (third-party, paid).
- Don't ship the "Advanced → unsafe" click-through to real users.
In that case, publishing-status-to-production is still step one (it fixes token longevity), but verification becomes a real requirement for everything else.
Why this writeup exists
Search "Google OAuth refresh token expires in 7 days" and you'll find a lot of correct-but-incomplete answers that stop at "publish your app / it's because you're in Testing." The piece that trips people up is the very next assumption — that production requires verification, so a hobbyist with a restricted scope feels stuck behind a weeks-long review for a token they could have had in two minutes. The two axes are independent. That's the whole insight.
Found this useful?
If this saved you from writing a weekly re-auth cron, a ⭐ helps others find it. I write up the non-obvious gotchas from building small Google / Next.js / automation projects — issues and corrections welcome. If you'd like help wiring up production-grade single-user OAuth (MCP servers, headless token refresh, multi-account separation), see GitHub Sponsors.
License
MIT — see [LICENSE](LICENSE). Do whatever you want with the recipe.
Source & license
This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: MankhongGarden
- Source: MankhongGarden/google-oauth-nonexpiring-refresh-token
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.