Skip to main content
Build an MCP client that connects to Notion MCP using OAuth 2.0 with PKCE.

Connection requirements

Notion provides a hosted MCP (Model Context Protocol) server that lets MCP clients work with Notion workspaces. The server is available at: Both endpoints support the same MCP protocol and OAuth authentication. Your client should try Streamable HTTP first and fall back to SSE if needed. Your client must support:
  • OAuth 2.0 Authorization Code flow with PKCE
  • Support for Streamable HTTP (/mcp) or SSE (/sse) transports
  • Token refresh handling
  • Secure credential storage

Prerequisites

This guide uses TypeScript/JavaScript examples, but the concepts apply to any programming language. The OAuth 2.0 flow, PKCE implementation, and MCP protocol are language-agnostic.
Required libraries (TypeScript/JavaScript):

Alternative libraries for other languages

  • MCP SDK: go-sdk (official)
  • OAuth 2.0: golang.org/x/oauth2 (official extended package)
  • PKCE: Supported via oauth2.SetAuthURLParam("code_challenge", ...) and oauth2.SetAuthURLParam("code_challenge_method", "S256")
  • MCP SDK: rust-sdk (official)
  • OAuth 2.0: oauth2 crate
  • PKCE: Built into oauth2 crate via PkceCodeChallenge and PkceCodeVerifier
  • MCP SDK: Use HTTP client libraries (Apache HttpClient, OkHttp, or Java 11+ HttpClient)
  • OAuth 2.0: Spring Security OAuth2 (recommended) or ScribeJava
  • PKCE: Built into Spring Security OAuth2 Client; supported in ScribeJava via PKCE configuration
  • MCP SDK: Use Net::HTTP (standard library) or Faraday
  • OAuth 2.0: oauth2 gem
  • PKCE: Supported via oauth2 gem with appropriate configuration

Key references

Step 1: OAuth discovery

Before connecting to an MCP server, discover its OAuth configuration. Given the MCP server URL (e.g., https://mcp.notion.com/mcp), use a standard two-step discovery process:
  1. RFC 9470: Fetch Protected Resource Metadata to find which authorization server(s) protect this resource
  2. RFC 8414: Fetch Authorization Server Metadata to get OAuth endpoints

Understanding the discovery flow

An MCP server (the protected resource) might be hosted at mcp.example.com but delegate authentication to a separate OAuth server at auth.example.com. The Protected Resource Metadata tells you where to find the authorization server, and the Authorization Server Metadata tells you the specific OAuth endpoints to use.

Standard discovery implementation

Here’s a function that implements the complete RFC 9470 → RFC 8414 discovery flow:
What this does:
  1. Fetches Protected Resource Metadata from https://mcp.notion.com/mcp/.well-known/oauth-protected-resource — Returns: { "authorization_servers": ["https://..."], ... }
  2. Extracts the authorization server URL from the authorization_servers array
  3. Fetches Authorization Server Metadata from {authServerUrl}/.well-known/oauth-authorization-server — Returns: OAuth endpoints like authorization_endpoint, token_endpoint, etc.
  4. Validates that all required fields are present and warns if PKCE support isn’t advertised
This approach is universal and works for any MCP server that follows RFC 9470 and RFC 8414 standards, not just Notion’s MCP server.

Step 2: Generate PKCE parameters

PKCE (Proof Key for Code Exchange) is mandatory for secure OAuth flows. Generate a code verifier and challenge:
The codeVerifier must be kept secret and never sent to the authorization server until the token exchange step. Store it securely (encrypted session, secure cookie, or in-memory with short expiry).

Step 3: Dynamic client registration

Notion MCP server supports dynamic client registration (RFC 7591). Check if registration_endpoint exists in the metadata:

Step 4: Initiate authorization flow

Redirect the user to the authorization endpoint with PKCE parameters:
Security best practices:
  • Always use HTTPS for redirect URIs in production
  • Store state and codeVerifier securely (encrypted session storage)
  • Set a short expiry (10 minutes) for stored values
  • Validate state on callback to prevent CSRF attacks

Step 5: Handle OAuth callback

After user authorizes, they’ll be redirected back to your redirectUri with an authorization code:

Step 6: Exchange authorization code for tokens

Exchange the authorization code for access and refresh tokens:
A successful authorization-code exchange also says who connected. Refresh responses don’t include these fields, so store them from the first exchange. user_id and workspace_id come as a pair. If Notion can’t resolve them, the response has none of these fields. email_domain is left out when the user has no email address. bot_id, workspace_name, and workspace_icon are left out when Notion doesn’t have the connection’s details, and the two workspace fields can be null. To get the user’s name or email address, or to look up identity later, see Identify the connected workspace.
Notion may add fields to the token response over time. Parse it leniently and ignore fields you don’t recognize.
Token storage security:
  • Web applications: Store tokens server-side only, never in localStorage or cookies
  • Desktop applications: Use secure credential storage (Keychain on macOS, Credential Manager on Windows)
  • Mobile applications: Use secure keychain/keystore APIs
  • Always encrypt tokens at rest

Step 7: Connect to MCP server with authentication

Notion’s MCP server supports two transport protocols. Your client should try Streamable HTTP first and automatically fall back to SSE if needed.

Session ids on the Streamable HTTP endpoint

The /mcp endpoint may return an Mcp-Session-Id header on the response to your initialize request. Per the Streamable HTTP transport spec, a client that receives one must send it back in an Mcp-Session-Id header on every later request in that session. The official MCP SDKs do this for you. A hand-written HTTP client needs to capture the header and echo it. Notion’s /mcp endpoint is stateless, so the id carries no server-side state:
  • The id is opaque. Don’t parse it or attach meaning to its contents.
  • Notion doesn’t validate or expire it. A missing, unknown, or stale session id is never rejected on this endpoint, so there is no 400 Bad Request or 404 Not Found session error to recover from and no session to re-establish.
  • Echoing it lets Notion group the requests of one client session together, which helps when you report an issue — include the id in your report.

Identify the connected workspace

Use the token response fields first. They give you user_id and workspace_id without another request. For a user-authorized connection, call notion-get-self with an empty object to read the person who connected, including their name and email when available. If user.type is bot, the connection acts as a workspace bot and no person email is available; use user_id from the authorization-code token response to identify the person who authorized the connection.
The text block contains a JSON object with stable workspace and user fields. See Supported tools for their shapes. Older clients can still call notion-fetch with the id self. That response puts the same identity under a self field. New clients should use notion-get-self. An MCP access token works only with Notion MCP. Don’t send it to the REST API, for example to GET /v1/users/me. OpenID Connect (OIDC) is the standard OAuth option for receiving identity in an ID token or from a UserInfo endpoint. It isn’t available from Notion MCP yet. If Notion enables OIDC for your client, request openid. Add profile for the user’s name and email for the user’s verified email address. Notion grants these identity scopes only to clients the Notion team allows. The consent page tells the user, “See your name and email address.” Notion omits the email claim when the email isn’t verified. To check whether a token is still active, and who it belongs to, send it to the introspection_endpoint from the authorization server metadata. This endpoint follows RFC 7662. Send a POST with a form-encoded token parameter, and authenticate with the same method you use at the token endpoint. Public clients send only client_id. A client can only introspect its own tokens.
An active token returns active: true with client_id, scope, iat, sub, and iss, plus user_id, exp, and aud when Notion has them. Access tokens also return token_type: "bearer" and, when Notion has it, workspace_id. An expired, revoked, or unknown token, or a token issued to another client, returns only { "active": false }.

Check tool availability

You don’t need to check tool access before you call tools. Each tool enforces its own access limits when you call it. Use notion-search for every content search. It runs AI search when the user has it, and keyword search otherwise. Workspace-owned connections keep the earlier tool list: use the search tool the connection lists, which can be notion-ai-search. See Supported tools for that case. If your client wants plan or parameter details up front, call notion-get-tool-access with {} once and reuse the map for this connection.
The map includes only tools the server exposes to this connection. In sessions that list one search tool, it still reports AI search access under ai_search. When that status is available, the map leaves out search, so request both keys if you narrow it with tool_names. Keep calling notion-search. A user lookup (query_type: "user") on the listed search tool has separate requirements: it needs user information capabilities, and workspace-owned MCP connections must also expose notion-get-users. AI search access doesn’t grant these permissions. Read each restricted_parameters reason against your requested value. A listed parameter isn’t always forbidden: a restriction on multiple teamspaces still allows a single teamspace. An upgrade_required status means full access needs an upgrade; it doesn’t rule out documented fallback results. See Supported tools for all access statuses, recovery links, and search behavior.

Step 8: Handle token refresh

Access tokens expire. Implement automatic refresh with proper error handling:
Many servers rotate refresh tokens for security (RFC 6749 Section 10.4). Always store the new refresh_token if provided in the response.

Complete example

Here’s a complete class that ties all the steps together:

Security best practices

  1. Always use HTTPS — Never use HTTP except for localhost development
  2. Validate state parameter — Always verify state matches stored value on callback
  3. Secure token storage — Encrypt tokens at rest, never expose to client-side code
  4. PKCE is mandatory — Always use PKCE even if server doesn’t advertise support
  5. Token expiry handling — Check token expiry before each request, refresh proactively
  6. Error handling — Handle invalid_grant errors gracefully (re-authentication required)
  7. HTTPS verification — Validate SSL certificates in production
  8. Rate limiting — Implement rate limiting for token refresh to prevent abuse
  9. Scope minimization — Only request the scopes you actually need
  10. Audit logging — Log all OAuth operations for security auditing

Troubleshooting

  • Store the state securely and validate on callback
  • Expire after ~10 minutes
  • Use the exact verifier that produced the code_challenge
  • Ensure base64url encoding (no +, /, =)
  • Use RFC 9470 (protected resource) then RFC 8414 (authorization server)
  • Confirm server supports OAuth and your URL is correct
  • Prefer Streamable HTTP; fall back to SSE
  • Check proxy or firewall rules
  • Do token exchange server-side; browser should only handle redirects
When refresh returns { "error": "invalid_grant" }, the refresh token is invalid, expired, revoked, or superseded by rotation. Do not retry refresh; prompt re-authentication.Common causes:
  1. Rotation on use — Providers rotate refresh tokens and revoke the old one. Fix: Persist the new refresh_token atomically with the access token.
  2. Expired refresh token — Often 30–90 days. Fix: Re-authenticate; monitor early expirations.
  3. Client credential mismatch — client_id or client_secret differs from initial auth. Fix: Keep credentials consistent for the token’s lifetime.
  4. Explicit revocation or policy event — User revoked access, password change, or security policy. Fix: Show clear reconnect UI.
  5. Concurrent refreshes — Parallel refreshes cause losers to see invalid_grant. Fix: Use a mutex or distributed lock around refresh.
Operational guidance:
  • invalid_grant → re-authenticate
  • temporarily_unavailable or network errors → retry with backoff
  • Refresh 5–10 minutes before expiry to avoid races
  • Cache access tokens with accurate expiry
If your client exchanges an ID-JAG from an identity provider instead of a refresh token, invalid_grant means Notion rejected the assertion. See Enterprise-managed authorization for the claims Notion checks and the common causes.

Notion MCP OAuth specifics

Notion’s remote MCP server is built on Cloudflare’s workers-oauth-provider package. Its token lifecycle has a few behaviors worth designing your client around. The guarantees below are the ones you can rely on; build for them as the strictest case.

Token lifecycle

  • Access tokens currently last about eight hours, but this duration is subject to change. Always use the token response’s expires_in value and refresh proactively rather than hard-coding a lifetime.
  • Refresh tokens expire under either of two conditions, whichever comes first: an absolute maximum lifetime of 180 days measured from when the user first authorized the connection (this cap does not slide — refreshing does not extend it), or 30 consecutive days of inactivity (no successful refresh). An actively used connection keeps working until the 180-day mark; an idle one lapses after 30 days. In either case the next refresh returns invalid_grant and the user must re-authorize. Treat periodic reconnection as expected, not exceptional, and make sure your reconnect flow is easy to reach.
For reliable long-lived connections:
  • Persist dynamic client registration credentials (client_id and client_secret) durably and reuse them, because re-registering orphans prior grants. Alternatively, use a Client ID Metadata Document (CIMD), which Notion MCP supports.
  • Persist each rotated refresh_token atomically and serialize refreshes per grant. Never refresh the same grant concurrently from multiple processes.
  • Treat invalid_grant as terminal: clear the stored tokens and reauthorize once. Do not retry-loop.

Refresh token rotation

Every refresh rotates the refresh token: the token response returns a new refresh_token, and the one you sent is retired. To keep a connection healthy:
  • Persist the new refresh_token from each refresh response atomically with the new access token, before you issue the next request.
  • A grant keeps at most two refresh tokens valid at once — the current one and the immediately previous one (a one-step window). If a transient failure prevents you from storing a rotated token, you may retry once with the previously stored token.
Reusing a refresh token that has already been rotated away can revoke the entire connection. As a theft-containment measure, replaying a refresh token that was rotated out more than a brief grace period earlier is treated as a stolen-token signal: the server revokes the whole grant. Every access and refresh token for that connection stops working, and the user must re-authorize from scratch. To stay clear of it:
  • Serialize refreshes per connection with a mutex or distributed lock. Never refresh the same connection from two workers or replicas concurrently — distributed setups that share a connection without a consistent, atomic token store are the most common cause of accidental reuse.
  • Treat invalid_grant as terminal for the connection: drop the stored tokens and surface re-authentication. Do not retry a refresh that returned invalid_grant. A revoked or expired grant cannot recover, and retry loops against it only generate load and never succeed.

Handling invalid_grant

invalid_grant from the token endpoint always means the connection is dead and the user must reconnect, whether the cause is refresh-token reuse, the 30-day lifetime, an explicit revocation, or a credential mismatch. Stop refreshing that connection, clear its tokens, and prompt re-authorization. See Troubleshooting above for the full list of causes and fixes.

Optional: MCP server discovery via mcp.json

The mcp.json convention described here is not part of the MCP specification. It is an unofficial convention, championed by Notion and Cursor, that MCP clients can optionally support to improve the user experience.
MCP clients can discover available MCP servers by checking for a /.well-known/mcp.json file on a website’s domain. When a user pastes a link, the client can detect the MCP server and offer to connect to it. For example, Notion hosts its discovery file at:
The file contains:

Schema

How to use this in your client

When a user pastes a URL (e.g., https://www.notion.com/some-page), your client can check for /.well-known/mcp.json on that domain. If the file exists, your client can:
  1. Show a prompt suggesting the user connect to the MCP server for richer interaction
  2. Auto-connect if the user has previously authorized the server
  3. Use the MCP server to fetch structured data instead of scraping the web page

Publishing your own mcp.json

If you operate an MCP server, you can publish a mcp.json file at /.well-known/mcp.json on your domain so that MCP clients can discover your server automatically.

Additional resources