Skip to content
Skip to main content

invalid_grant error: causes and fixes

Last updated:

Your integration ran fine for weeks, then one user’s email stopped syncing and your logs filled with invalid_grant. Nothing in your code changed. The user changed their password, revoked access, or the provider expired the refresh token, and the stored credential is now dead. This is the single most common runtime OAuth failure, and the fix is almost always re-authentication, not a code change.

invalid_grant is a token-layer error, distinct from the setup mistakes that block the consent screen. If your error happens during connection (redirect URI mismatch, missing scopes, admin consent), start with troubleshoot OAuth errors instead. This page is for grants that connected successfully and later went bad.

The invalid_grant error means the provider rejected the stored refresh token, so the grant can no longer mint new access tokens. The common triggers are a password change, a multi-factor authentication (MFA) or security reset, an admin revoking your app in the provider console, an expired refresh token, or a deleted or mismatched OAuth client. Nylas then flips the grant’s grant_status from valid to invalid. The grant lifecycle diagram shows that transition, the way back through re-authentication, and the notification Nylas sends at each step.

Grant lifecycle and notifications

An expired grant keeps its ID, data, and object IDs until the user re-authenticates. Deleting a grant is permanent, and the user's next sign-in creates a new grant ID. Changing a valid grant's settings also sends grant.updated.
Diagram as text
  • States:
    • valid: API requests and notifications work.
    • invalid: Grant, data, and object IDs are kept.
    • Deleted: Next sign-in creates a new grant ID.
  • Transitions:

The OAuth 2.0 spec (RFC 6749, section 5.2) defines invalid_grant as an authorization grant or refresh token that’s “invalid, expired, revoked” or was issued to another client. With Nylas you rarely see the raw string, because the API holds the provider tokens for you and surfaces the failure as an invalid grant plus a grant.expired webhook. The table below maps each of the 6 triggers to what happened and how to recover.

TriggerWhat happenedFix
Password changeUser reset their email password, invalidating the tokenRe-authenticate the same grant
MFA or security resetUser reset multi-factor auth, which revokes existing sessionsRe-authenticate the same grant
Access revokedUser or admin removed your app in the provider’s consoleRe-authenticate after they re-consent
Refresh token expiredProvider expired an idle or aged token (Google: 6 months idle)Re-authenticate the same grant
App credentials changedThe client secret on the connector changed, breaking refreshUpdate the connector, then re-authenticate
Scopes changedYou requested new scopes the user hasn’t consented toRe-authenticate to capture the new consent

A few of these are worth expanding because the provider behavior differs. When a user changes their password, Google revokes refresh tokens that hold Gmail scopes, and Microsoft Entra revokes password-based tokens on a password change, a self-service password reset, or an admin reset. An admin revoking app access in the Google Admin console or Microsoft Entra admin center kills every grant for that tenant at once, so an invalid_grant that hits many users on the same domain simultaneously usually points here, not at individual user action.

Changing your app credentials without updating the connector breaks every refresh, because Nylas keeps sending the old client secret. Update the connector’s client secret before you disable the old one. Per Google’s client secret rotation help, deleting the OAuth client invalidates its refresh tokens, and every user must re-authenticate.

Google and Microsoft each publish the reasons a refresh token stops working. Google lists them under refresh token expiration:

  • Access revoked: the user removed your app’s access from their Google Account.
  • 6 months unused: the refresh token hasn’t been used for six months.
  • Password change: the user changed their password and the refresh token contains Gmail scopes.
  • Testing-mode app: a project with an external user type and a publishing status of “Testing” gets refresh tokens that expire in 7 days, unless it requests only name, email, and profile scopes. This is the most common cause of invalid_grant in development.
  • 100-token limit: Google allows 100 refresh tokens per Google Account per OAuth 2.0 client ID. Creating a new one past that limit invalidates the oldest without warning.
  • Time-based access expired: the user granted time-limited access and that window ended.

Microsoft Entra returns invalid_grant when “some of the authentication material (auth code, refresh token, access token, PKCE challenge) was invalid, unparseable, missing, or otherwise unusable,” and puts an AADSTS code in the error_description that names the cause. These codes from the Microsoft Entra error code reference describe a dead refresh token:

  • AADSTS50173: the grant was revoked and a fresh auth token is needed. The user might have changed or reset their password.
  • AADSTS700082: the refresh token expired due to inactivity. Microsoft refresh tokens have a 90-day default lifetime, and each use returns a fresh token.
  • AADSTS70008: ExpiredOrRevokedGrant, the refresh token expired due to inactivity.
  • AADSTS70000: InvalidGrant, the refresh token isn’t valid.

An invalid grant surfaces two ways. The proactive signal is the grant.expired webhook, raised by a background sync health check that runs roughly every 10 minutes, so subscribe to it. The reactive signal is a 401 on a live request, which is how you catch a grant that broke between health checks on a mailbox you weren’t actively polling.

The request below subscribes a webhook to the grant.expired trigger by calling POST /v3/webhooks/. The notification payload carries the affected grant_id, so your handler can flag that one user for re-authentication without scanning every grant. Webhook delivery beats polling GET /v3/grants/{id}, which wastes rate-limit budget on grants that are still valid.

When the webhook fires, or when a call returns 401, mark the user as needing re-authentication in your own database and surface a reconnect prompt. For the full detection and recovery pattern, including the webhook backfill, see handle grant expiry.

Confirm a suspected invalid grant before you act on it by reading grant_status from GET /v3/grants/{grant_id}. The field is an enum with exactly two values, valid and invalid, so a single field tells you whether the credential is dead. Don’t treat every failed request as an invalid_grant: a 429 (rate limited), a 5xx, or a network timeout is transient and should be retried with backoff, while a 401 paired with grant_status: invalid is permanent and only re-authentication clears it. Checking the status field first stops you from pushing a user through OAuth when the real problem was a 30-second provider blip.

Fix it by routing the user through the same OAuth flow that first connected them. Re-authenticating an email that already has a grant updates that grant in place rather than minting a new one, so the grant_id, every object ID, and the sync state survive. Don’t delete and recreate the grant: that throws away every stored reference and the backfilled history.

The code below builds the same /v3/connect/auth URL you used for the first connection, with login_hint set to the affected user’s email so the provider pre-fills their account. Redirect the user there, they re-approve, and the grant returns to valid status. Set access_type to offline so the provider issues a fresh refresh token instead of an access-only token that expires in about 3,600 seconds.

After the user re-approves, your callback exchanges the new code at POST /v3/connect/token exactly as it did on first connect. The get and refresh OAuth tokens recipe covers that exchange and why the API holds the provider tokens for you.

Why doesn’t refreshing the token fix it on its own?

Section titled “Why doesn’t refreshing the token fix it on its own?”

You can’t refresh your way out of an invalid_grant, because the refresh token itself is what the provider rejected. A refresh exchanges a valid refresh token for a new access token; once the refresh token is revoked or expired, there’s nothing left to exchange. Only the user re-granting consent issues a new refresh token, which is why re-authentication, not a token refresh, is the common thread in every fix above (a rotated client secret just needs the connector updated first).

This is the practical reason Nylas manages token refresh for you. In API-key mode the API renews the access token before it expires, so transient expiries never reach your code. The grant only goes invalid when the underlying refresh token dies at the provider, an event no automated refresh can recover. See get and refresh OAuth tokens for how automatic refresh works and when a grant still breaks.

It helps to picture the two tokens as a pair with different jobs. The access token is short-lived, expiring after about 3,600 seconds, and the refresh token is the long-lived credential that mints replacement access tokens. Automatic refresh works on a loop: present the refresh token, receive a new access token, repeat. An invalid_grant breaks the loop at its source because the refresh token is exactly what the provider rejected. There is no second credential to fall back on. This is why retry logic and exponential backoff, which fix transient 429 and 5xx failures, do nothing for an invalid_grant: every retry replays the same dead refresh token and gets the same rejection. The only thing that issues a new refresh token is the user re-granting consent at the provider, so re-authentication isn’t one option among several, it’s the entire recovery path.

How do I build a graceful re-auth experience?

Section titled “How do I build a graceful re-auth experience?”

A graceful re-auth flow detects the invalid grant, pauses work against it, tells the user in plain language, routes them through the OAuth URL, and resumes once the grant returns to valid. The goal is to recover the connection with one click and zero lost data, since re-authentication preserves the grant ID, object IDs, and sync state.

Run the recovery as five ordered steps so a broken connection becomes a self-service fix instead of a support ticket:

  1. Detect and mark. On a 401 or a grant.expired webhook, set a needs_reauth flag on the user record and store the timestamp. This flag drives every downstream decision.
  2. Pause syncs and writes. Stop queueing API calls for that grant. Continuing to hammer an invalid grant burns rate-limit budget and floods your logs with the same 401.
  3. Notify the user. Email them that the connection expired and that they need to reconnect. The faster they act, the smaller the data gap, because Nylas only backfills missed notifications if the grant was invalid for less than 72 hours.
  4. Send them through re-auth. Link to the OAuth URL built in the fix section above, with login_hint set to their email so the provider pre-fills the right account.
  5. Resume on grant.updated. Nylas fires a grant.updated webhook after a successful re-auth. Clear the needs_reauth flag, re-enable syncs, and process the backfill burst that arrives for gaps under 72 hours.

Surface the paused state in your UI as a “Reconnect account” banner rather than a silent failure. A visible prompt tied directly to the OAuth link turns a dead integration into a one-click fix, and it stops users from assuming your product is broken when the real issue is an expired provider token.

How do I prevent invalid_grant errors from going unnoticed?

Section titled “How do I prevent invalid_grant errors from going unnoticed?”

You can’t stop providers from expiring tokens, but you can guarantee you find out within 10 minutes instead of hours. Three controls cover it: subscribe to the grant.expired webhook, run a periodic grant-status sweep, and expose a reconnect state in your UI so users self-recover before they notice missing data.

The webhook is your fastest signal. Nylas detects expired grants through sync health checks and fires grant.expired within about 10 minutes, while an unqueried grant can otherwise sit broken until your next API call hours later. Back the webhook with a scheduled sweep that pages through GET /v3/grants and counts how many have grant_status: invalid. The sweep catches anything a missed or undelivered webhook left behind, and the invalid count is a clean health metric: a sudden jump across one email domain usually means an admin revoked your app for that whole tenant at once.

Run the sweep on a schedule (daily is enough for most apps, since the webhook handles real-time alerts) and alert when the invalid count crosses a threshold you set. Pairing the proactive webhook, the periodic sweep, and a visible reconnect prompt means most users fix their own connection before they ever email support, which is the difference between a quiet recovery and a churn event.