Why does my Google Calendar refresh token expire after 7 days?
Updated
TL;DR
An app with the External user type and the Testing publishing status "is issued a refresh token expiring in 7 days", so calendar access stops a week after consent with invalid_grant and "Token has been expired or revoked." The fix is to publish the app to In production and have users authorize again. Tokens also stop working when a user revokes access, after six months unused, or when an account passes 100 live refresh tokens for one client.
The error
When a refresh token stops working, Google's token endpoint answers the refresh request with HTTP 400 and this body:
{
"error": "invalid_grant",
"error_description": "Token has been expired or revoked."
}Client libraries wrap it. In Python, google-auth raises a RefreshError whose message starts with invalid_grant: Token has been expired or revoked. The body is the same whatever the cause, which is why this error is so hard to debug.
The 7-day rule
Google's OAuth 2.0 overview states it directly: a Google Cloud project with an External user type and a publishing status of "Testing" "is issued a refresh token expiring in 7 days", "unless the only OAuth scopes requested are a subset of name, email address, and user profile".
Calendar scopes are not in that subset, so every calendar app in Testing gets 7-day tokens. Google's page on publishing status repeats it: "Authorizations by a test user will expire seven days from the time of consent", and if the client asked for offline access, "that token will also expire".
The pattern is easy to recognize. Everything works in development, then a week later every connected account fails at once, or fails one by one on the anniversary of each user's consent.
How to stop it
Move the app out of Testing.
- In the Google Cloud console, open the OAuth consent screen settings and change the publishing status from Testing to In production.
- Have every user authorize again. Developers report that tokens issued while the app was in Testing still expire on schedule after the switch, so only new consents get long-lived tokens.
In production, anyone with a Google Account can sign in. Until Google verifies the app, they see the "Google hasn't verified this app" warning first, and the app is limited to "100 new users in total" for the life of the project. For a product with real users, In production means going through Google's OAuth verification as well.
Switching the user type to Internal also avoids the 7-day rule, but only accounts in your own Google Workspace organization can then sign in. That rarely fits an app with customers.
Other reasons a refresh token stops working
Production apps get invalid_grant too. Google's list, and what each means for a calendar app:
The user revoked access
"The user has revoked your app's access." Users can remove apps from their Google Account's third-party access page at any time. This one needs no fix on your side, only a clear way to reconnect.
Six months unused
"The refresh token has not been used for six months." An app that refreshes on a schedule never hits this. One that only refreshes when a user opens it can, for accounts that go quiet.
Too many refresh tokens
"There is currently a limit of 100 refresh tokens per Google Account per OAuth 2.0 client ID. If the limit is reached, creating a new refresh token automatically invalidates the oldest refresh token without warning." Older sources say 50 or 25; Google's current page says 100.
The usual cause is sending prompt=consent on every sign-in. Each consent issues a new refresh token, and after 100 the oldest one dies. If you store tokens per device or per session, the one that dies can be a token you still use. Ask for access_type=offline and prompt=consent only when you need a new refresh token, and keep one per user.
Password changes, for Gmail scopes
"The user changed passwords and the refresh token contains Gmail scopes." Google's current wording ties this to Gmail scopes, so a token with only calendar scopes should survive a password change. If you request Gmail and Calendar together, it won't.
Time-based access ran out
Google's time-based access lets users grant access "for a limited duration to complete an action". When they do, the token response includes refresh_token_expires_in, the time left before the refresh token expires. Read it and store it, rather than assuming the token lasts.
An administrator restricted the scopes
A Google Workspace administrator can restrict a service such as Calendar for their organization. Refreshing then fails with admin_policy_enforced. Only the administrator can undo it, by trusting your app.
Google Cloud session control
Google Cloud administrators can require users to sign in again after a set time. Google lists this cause for Google Cloud Platform APIs, so a calendar-only app is unlikely to meet it. When it happens, the error has an error_subtype such as invalid_rapt, the one case where the body tells you the cause.
Telling the causes apart
Outside the session control case, you can't. Google's developer forum has 2026 threads asking for an error_subtype that would separate a revoked token from an expired one, without an answer. There are also unexplained revocations reported in verified apps, with no response from Google yet.
So treat invalid_grant on a refresh as permanent:
- Don't retry it. It will not succeed, and retry loops hide the problem.
- Mark the account as disconnected, with the time it happened.
- Tell the user, and send them through consent again with
access_type=offlineto get a new refresh token.
Google's own advice: "You must write your code to anticipate the possibility that a granted refresh token might no longer work."
When a user disconnects on purpose, revoke the token by sending it to https://oauth2.googleapis.com/revoke. Google answers 200 on success and 400 with an error code otherwise, and notes that "it might take some time before the revocation has full effect".
How Pyas handles it
With Pyas Auth, your users approve Pyas on Google's own consent screen. Google has already verified Pyas, so its tokens are not Testing tokens and do not expire after 7 days, and you don't go through Google's OAuth review. That holds on every plan, the free one included.
Pyas stores and refreshes the tokens. The other causes above can still end a connection, so Pyas reports them: GET /v2/accounts/{accountId}/status says whether an account works and, if not, why and what fixes it. The connection.broken webhook fires when an account stops working. To reconnect it, create a connect session with the account's accountId, and PyasConnect opens straight to that account in your own app. The account keeps its accountId.
Common questions
Does verification fix the 7-day expiry?
Publishing to In production fixes it, verified or not. Verification lifts the unverified app warning and the 100-user cap, which you need anyway once the app has users.
Do I need to reconnect users after switching to In production?
Yes. Developers report that tokens issued in Testing keep their 7-day expiry, so each user has to authorize again to get a token that lasts.
Why does my token expire even though the app is in production?
Check the other causes above, starting with the 100-token limit if you send prompt=consent on every sign-in. If none fit, treat it as a revocation and ask the user to reconnect.
Do service accounts have the 100-token limit?
No. Google says service accounts are exempt from it. They have a different limit for calendars: they cannot invite attendees without domain-wide delegation.