# Fix OAuth invalid_grant token errors

Source: https://developer.nylas.com/docs/cookbook/use-cases/build/fix-invalid-grant-errors/

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](/docs/cookbook/use-cases/build/troubleshoot-oauth-errors/) instead. This page is for grants that connected successfully and later went bad.

## What causes the invalid_grant error?

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.

| Trigger                  | What happened                                                       | Fix                                          |
| ------------------------ | ------------------------------------------------------------------ | -------------------------------------------- |
| Password change          | User reset their email password, invalidating the token            | Re-authenticate the same grant               |
| MFA or security reset    | User reset multi-factor auth, which revokes existing sessions      | Re-authenticate the same grant               |
| Access revoked           | User or admin removed your app in the provider's console           | Re-authenticate after they re-consent        |
| Refresh token expired    | Provider expired an idle or aged token (Google: 6 months idle)     | Re-authenticate the same grant               |
| App credentials rotated  | The client secret on the connector changed, breaking refresh       | Update the connector, then re-authenticate   |
| Scopes changed           | You requested new scopes the user hasn't consented to              | Re-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.

## How do I detect an invalid_grant grant?

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.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks/' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "trigger_types": ["grant.expired"],
    "webhook_url": "https://yourapp.com/webhooks/nylas"
  }'
```

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](/docs/cookbook/use-cases/build/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.

## How do I fix an invalid_grant error?

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.

```bash
curl --request GET \
  --url 'https://api.us.nylas.com/v3/connect/auth?client_id=<NYLAS_CLIENT_ID>&redirect_uri=https%3A%2F%2Fyourapp.com%2Fcallback&response_type=code&access_type=offline&provider=google&login_hint=user@example.com'
```

```js [reauth-Node.js SDK]
const authUrl = nylas.auth.urlForOAuth2({
  clientId: process.env.NYLAS_CLIENT_ID,
  redirectUri: "https://yourapp.com/callback",
  provider: "google",
  accessType: "offline",
  loginHint: user.email, // pre-fills the account that went invalid
});

res.redirect(authUrl);
```

```python [reauth-Python SDK]
auth_url = nylas.auth.url_for_oauth2({
    "client_id": os.environ.get("NYLAS_CLIENT_ID"),
    "redirect_uri": "https://yourapp.com/callback",
    "provider": "google",
    "access_type": "offline",
    "login_hint": user.email,  # pre-fills the account that went invalid
})

# redirect the user to auth_url
```

```ruby [reauth-Ruby SDK]
auth_url = nylas.auth.url_for_oauth2({
  client_id: ENV["NYLAS_CLIENT_ID"],
  redirect_uri: "https://yourapp.com/callback",
  provider: "google",
  access_type: "offline",
  login_hint: user.email # pre-fills the account that went invalid
})

# redirect the user to auth_url
```

```java [reauth-Java SDK]
UrlForAuthenticationConfig config = new UrlForAuthenticationConfig.Builder(
    System.getenv("NYLAS_CLIENT_ID"),
    "https://yourapp.com/callback")
    .accessType(AccessType.OFFLINE)
    .provider(AuthProvider.GOOGLE)
    .loginHint(user.getEmail()) // pre-fills the account that went invalid
    .build();

String authUrl = nylas.auth().urlForOAuth2(config);
// redirect the user to authUrl
```

```kotlin [reauth-Kotlin SDK]
val config = UrlForAuthenticationConfig.Builder(
    System.getenv("NYLAS_CLIENT_ID"),
    "https://yourapp.com/callback")
    .accessType(AccessType.OFFLINE)
    .provider(AuthProvider.GOOGLE)
    .loginHint(user.email) // pre-fills the account that went invalid
    .build()

val authUrl = nylas.auth().urlForOAuth2(config)
// redirect the user to authUrl
```

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](/docs/cookbook/use-cases/build/get-refresh-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?

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](/docs/cookbook/use-cases/build/get-refresh-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?

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](#how-do-i-fix-an-invalid_grant-error), 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?

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.

```bash
curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants?limit=200' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
```

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.

## What's next

- [Handle grant expiry](/docs/cookbook/use-cases/build/handle-grant-expiry/) for the full detection, recovery, and webhook-backfill pattern
- [Troubleshoot OAuth errors](/docs/cookbook/use-cases/build/troubleshoot-oauth-errors/) for connection-time errors like redirect URI mismatch and missing scopes
- [Get and refresh OAuth tokens](/docs/cookbook/use-cases/build/get-refresh-tokens/) for token exchange and automatic refresh
- [Grant lifecycle](/docs/dev-guide/best-practices/grant-lifecycle/) for the complete grant status and re-authentication reference