Skip to content
Skip to main content

Fix OAuth invalid_grant token errors

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 rotated app credentials. Nylas then flips the grant’s grant_status from valid to invalid.

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 rotatedThe 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.

The refresh token expiry rules are the least obvious. Google refresh tokens expire after 6 months of no use, and any app still in “Testing” publishing status gets tokens that expire after just 7 days, which is the single most common cause of invalid_grant in development. Google also caps live refresh tokens at 100 per account per client ID and silently drops the oldest beyond that. Microsoft rolls its refresh tokens: each use returns a fresh one with a 90-day default lifetime, so a grant that sits unused past that window goes invalid. Rotating your app credentials (the OAuth client secret stored on the connector) invalidates every refresh token issued under the old secret, so update the connector and re-authenticate affected users after any secret rotation.

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.