Notion OAuth refresh token invalid_grant — What it means & how to fix it
How to diagnose and fix Notion refresh token invalid_grant errors
Connecting to Notion's API with OAuth 2.0? Sooner or later a refresh will fail. You'll usually see it as invalid_grant, which blocks page syncs, database reads, or automated workspace workflows.
This guide explains how to spot and fix Notion refresh token errors, and how to run token lifecycle in a way that avoids the usual pitfalls (including refresh races and rotation mix-ups). For broader context, see API Auth Is Deeper Than It Looks and Why OAuth Is Still Hard.
Spot the error
When your backend POSTs to Notion's token endpoint to exchange a refresh token for a new access token:
- Token endpoint:
https://api.notion.com/v1/oauth/token - Common HTTP status:
400 - Common OAuth error:
invalid_grant
Example response payload:
Notion OAuth error response
📋
{
"error": "invalid_grant",
"error_description": "Token has been expired or revoked."
}
Notion may also return a message (or code) with extra detail. Either way: the refresh token you have can't be used to get a new access token. Retrying the same request will keep failing until you fix the cause.
Why did Notion reject the refresh token?
Notion returns invalid_grant when the refresh token you send can't be exchanged for a new access token—because it's invalid, expired, revoked, or you're still using an old token after rotation. Treat invalid_grant as a token lifecycle issue, not a transient glitch.
Recurring causes:
Most common: You're not using the latest refresh token
Notion rotates refresh tokens: each successful refresh returns a new access token and a new refresh token, and the previous refresh token is invalidated. If you keep calling the token endpoint with an old refresh token, the next refresh can fail with invalid_grant.
The same pattern often appears when several processes or containers refresh without coordination: one stores the new refresh token, another overwrites it with the old one, and every refresh starts failing.
Redirect URI mismatch (Notion-specific)
The redirect_uri you send when exchanging the authorization code must exactly match the redirect URI used in the authorize request. If it doesn't (e.g. different scheme, host, or path), Notion can return invalid_grant. This is a frequent cause when dev uses one URI and production another, or when the token request is built with a different redirect_uri than the one used in the authorize URL.
Refresh token expired or revoked
If a refresh token has expired or been revoked (e.g. user disconnected the integration, or Notion revoked it), refresh will return invalid_grant. There's no way to "fix" the token; the user must go through the authorization flow again.
Access token expired before refresh
If the access token has expired and you try to refresh, you rely on the refresh token. If that refresh token is already invalid or expired, Notion returns invalid_grant and you need re-authorization.
User disconnected the integration
When a user removes your integration from their Notion workspace or revokes access, Notion invalidates the related tokens. Any later refresh with that refresh token will return invalid_grant.
Refresh request rejected → token revoked
A failed or rejected refresh (e.g. 400 or 401) can leave the refresh token invalid for subsequent use. Typical reasons: the refresh token was already rotated, the request used wrong client credentials, or the token had already been revoked.
Scope or app configuration changes
If the OAuth app's scopes or configuration change in Notion after authorization, existing refresh tokens can become invalid and require a new authorization.
Client credentials mismatch
Wrong client_id or client_secret in the token request (e.g. after rotating the secret, or using credentials from another integration) leads to invalid_grant. Double-check Basic auth and env vars.
Refresh-token concurrency (race conditions)
Refreshes can be triggered by many things: sync jobs, webhooks, "sync now" actions, or retries after 401s. If two workers refresh the same Notion connection at once, one can get a new refresh token while the other still uses the old one and gets invalid_grant (or overwrites state). If this sounds familiar, see our deep dive on OAuth token refresh concurrency.
Security or policy revocations
Notion may revoke refresh tokens based on security or policy (e.g. suspicious activity, abuse, or token compromise). That also shows up as invalid_grant on refresh.
How to fix it
1. Use the latest refresh token
If Notion returns a new refresh_token on refresh, always persist it and use that value for the next refresh. The previous refresh token is invalid as soon as a new one is issued.
- Store the latest
refresh_tokenreturned by Notion on every refresh. - Update the stored refresh token right after each refresh (and coordinate with any concurrency logic below).
2. Check your refresh request
Body: JSON with grant_type: "refresh_token" and refresh_token. Headers: Authorization: Basic <base64(client_id:client_secret)>, Content-Type: application/json, Notion-Version (e.g. 2025-09-03). URL: POST https://api.notion.com/v1/oauth/token.
3. Align redirect_uri (for code exchange)
When exchanging an authorization code, the redirect_uri in the token request must exactly match the one used in the authorize URL. Fix any mismatch between dev/prod or between frontend and backend.
4. Check if the user disconnected
If the user removed the integration or revoked access, the refresh token is dead. Don't retry indefinitely; mark the connection as needing re-auth and prompt the user to reconnect.
5. Eliminate refresh concurrency (single-flight + locking)
Treat "one refresh token per connection" as a shared resource: only one refresh in flight per connection, others wait and then use the new access token, and updates to (access_token, refresh_token, expires_at) are atomic.
Rather not build this yourself? Nango (open-source OAuth) handles refresh concurrency; your app just fetches the latest access token before each Notion API call.
6. When it's really invalid/expired/revoked: re-auth only
After ruling out rotation, redirect_uri, and credentials, treat invalid_grant as terminal for that connection: retry once if you want, then mark as "needs re-auth", pause background syncs, and ask the user to reconnect Notion. That keeps retries under control and gives users a clear next step.
How to prevent Notion refresh token issues
Use this checklist to cut down "why did my Notion integration break?" tickets:
- Refresh before expiry
Refresh access tokens before they expire so you don't hit edge cases with an already-revoked refresh token. - Always persist the new refresh token
After every successful refresh, overwrite the stored refresh token with the one Notion returned. - Drop old access tokens
After refreshing, stop using the old access token to avoid subtle auth bugs. - Single-flight, atomic refresh
One refresh per connection at a time; atomic persistence of tokens and expiry. If you're unsure, read How to handle concurrency with OAuth token refreshes. - Monitor invalid_grant
A baseline is normal; spikes often mean rotation, concurrency, or credential issues. - Handle disconnection
Detect when the integration is removed so you can clean up and nudge the user to reconnect. - Clear re-auth flow
A simple "Reconnect Notion" path reduces support load and confusion.
Skip the headache, let Nango refresh for you
Nango's open-source API auth offers:
- 600+ pre-built OAuth flows, including full support for all Notion APIs
- Automatic OAuth access token refreshing and rotation
- Webhooks when a refresh token is revoked, so you can warn the user instantly
- Built-in error handling for all OAuth edge cases
If you're building a Notion API integration and want to avoid token lifecycle edge cases, Nango can run the refresh pipeline for you.
Focus on product features and let Nango handle the token lifecycle.




