
Notion OAuth now mints unique tokens on every authorization — audit your integration now
Notion's June 8 OAuth change silently breaks re-authorization flows. Three concrete fixes for bot_id schemas, callback guards, and concurrency races.

Effective June 8, 2026. Zero community discussion as of today.
Prerequisites
| Requirement | Detail |
|---|---|
| Connection type | Public OAuth connection (affected); internal connections and PATs are not affected |
| Notion plan | Any plan that supports public integrations |
| Affected surfaces | Direct API integrations, Notion Workers with OAuth, Custom Agent MCP connections, n8n/Make/Zapier Notion OAuth nodes |
| Not affected | Connections created before June 8, 2026 that have not been re-authorized since |
What changed
On June 8, 2026, Notion quietly changed how it issues tokens for public connections. The changelog entry reads:
"New public connections now mint a freshaccess_tokenandrefresh_tokenfor each successful OAuth authorization instead of returning the existing active token. Existing connections keep their previous behavior. Store the token pair from every successful response — including re-authorizations of the same connection — as described in the Authorization guide." 1
The updated Authorization guide Step 5 makes it concrete: "Store the token pair from each successful authorization response. This includes re-authorization of the same connection, where Notion may return a new
access_token and refresh_token." 2Before June 8, re-authorizing a connection returned the same token pair that was already active. Now it always generates a new pair. Existing connections grandfathered before June 8 are unaffected — until they get re-authorized, at which point the new behavior kicks in.
This is distinct from the refresh token rotation Notion has always done (Step 6 of the authorization guide): each successful
POST /v1/oauth/token with grant_type: "refresh_token" already rotated tokens. What's new is that authorization now does the same.
invalid_grant. AI-generated diagram. Three things to audit
1. Using bot_id as your primary key
What breaks: The OAuth response returns
bot_id alongside access_token and refresh_token. Many integrations use bot_id as a primary key on the assumption that one bot → one token pair. That assumption is now false. The same bot_id can have multiple valid token pairs across different authorization sessions.Why it fails: Any
upsert keyed on bot_id will overwrite the old token pair with the new one — or the new one with the old, depending on timing. Once the wrong token expires, the next API call fails.Fix: Change your schema to a composite key or an append-only model:
-- Before (breaks)
CREATE TABLE notion_tokens (
bot_id TEXT PRIMARY KEY,
access_token TEXT,
refresh_token TEXT,
updated_at TIMESTAMP
);
-- After (safe)
CREATE TABLE notion_tokens (
id SERIAL PRIMARY KEY,
bot_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
authorized_at TIMESTAMP NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMP
);
-- Index: (bot_id, revoked_at) — latest non-revoked row is the active tokenWhen a new authorization comes in, insert a new row and mark older rows for that
bot_id as revoked rather than overwriting. 22. OAuth callback that skips "duplicate" tokens
What breaks: A common guard in OAuth callbacks looks like this:
// Danger pattern
const existing = await db.tokens.findOne({ bot_id });
if (existing) return; // "already have it, skip"
await db.tokens.save({ bot_id, access_token, refresh_token });After June 8, this guard silently throws away the new token pair. The old token continues to work until it expires — then the stored refresh token fails, and the integration breaks with no visible error at the moment the token was discarded.
Fix: Remove the early return. Always persist every successful OAuth response:
// Safe pattern
await db.tokens.upsert(
{ bot_id, authorized_at: new Date() },
{ access_token, refresh_token }
);
// or: insert new row + soft-delete previous rows for this bot_idThe key rule from the Notion authorization guide applies here without exception: store the token pair from every successful response. 2

invalid_grant — long after the token was dropped. AI-generated diagram. 3. Non-atomic token persistence across auth and refresh
What breaks: Notion has always rotated refresh tokens — each
POST /v1/oauth/token (refresh) returns a new access_token and new refresh_token, invalidating the previous refresh token. 2 The Nango blog documents this as the #1 root cause of invalid_grant errors: "you're not using the latest refresh token." 3Now that authorization also produces fresh tokens, a race condition is more likely: one process stores an authorization response while another concurrently stores a refresh response. One overwrites the other. If the losing write contained the valid refresh token, the next refresh fails.
Fix: Wrap all token writes in an atomic operation:
// Treat token storage as a critical section
await db.transaction(async (trx) => {
const latest = await trx.tokens
.where({ bot_id })
.orderBy('authorized_at', 'desc')
.first();
if (latest && latest.authorized_at > incomingAuthorizedAt) return; // already newer
await trx.tokens.insert({ bot_id, access_token, refresh_token, authorized_at: incomingAuthorizedAt });
});In serverless or multi-Worker environments, use a distributed lock (Redis
SET NX PX, database advisory lock, etc.) around the entire fetch-compare-write cycle. The Nango guidance applies directly: "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." 3Before / after behavior

Gotchas
Grandfathered connections are a delayed fuse. Connections created before June 8 keep the old behavior — until they are re-authorized. A user who clicks "Reconnect" in your app, or whose access expires and reconnects, will trigger the new behavior silently. Audit #2 (the callback guard) is the most likely failure point here.
n8n, Make, and Zapier's managed OAuth nodes. These platforms handle the OAuth callback internally, so they likely already persist every response correctly. But any custom webhook or OAuth proxy you built alongside them — for token forwarding, audit logging, or cross-workspace bridging — is your responsibility to audit.
invalid_grant is a lagging indicator. The integration won't visibly break at the moment the wrong token is stored. It breaks when the stale refresh token is first used, which could be hours or days later. By then, the storage bug is hard to trace.Zero community alarm so far. As of June 10, 2026, no discussion of this change appears on Reddit (
r/Notion, r/NotionAPI), YouTube, n8n community forums, or in Google search results. 1 The lack of chatter is not a signal that the change is minor — it means most teams will hit this on a future re-authorization, not during active monitoring today. Run the three audits before that happens.
Añade más opiniones o contexto en torno a este contenido.