The Notion SDK supports OAuth 2.0 for public integrations, allowing users to authorize your application to access their Notion workspace. The SDK provides three OAuth methods on the client.oauth namespace:
client.oauth.token() - Exchange authorization codes for access tokens
client.oauth.introspect() - Validate and inspect tokens
client.oauth.revoke() - Revoke access tokens
OAuth Flow Overview
Redirect user to Notion’s authorization URL
User approves your integration
Notion redirects back with an authorization code
Exchange code for access token using oauth.token()
Store tokens securely
Use access token to make API requests
Refresh token when it expires
Setting Up OAuth
Prerequisites
Environment Variables
NOTION_CLIENT_ID = your-client-id
NOTION_CLIENT_SECRET = your-client-secret
NOTION_REDIRECT_URI = https://yourapp.com/auth/callback
Step 1: Authorization URL
Redirect users to Notion’s authorization URL:
const authUrl = new URL ( "https://api.notion.com/v1/oauth/authorize" )
authUrl . searchParams . append ( "client_id" , process . env . NOTION_CLIENT_ID ! )
authUrl . searchParams . append (
"redirect_uri" ,
process . env . NOTION_REDIRECT_URI !
)
authUrl . searchParams . append ( "response_type" , "code" )
// Recommended: Use state parameter for CSRF protection
authUrl . searchParams . append ( "state" , generateRandomState ())
// Optional: Request specific permissions
authUrl . searchParams . append ( "owner" , "user" ) // or "workspace"
// Redirect user to authUrl.toString()
Step 2: Exchange Code for Token
After the user authorizes, Notion redirects back with a code parameter. Exchange it for an access token using oauth.token():
token() Method Signature
client . oauth . token (
args : OauthTokenParameters & {
client_id: string
client_secret : string
}
): Promise < OauthTokenResponse >
Authorization Code Grant
import { Client } from "@notionhq/client"
const notion = new Client ()
const tokenResponse = await notion . oauth . token ({
grant_type: "authorization_code" ,
code: "received-auth-code" ,
redirect_uri: process . env . NOTION_REDIRECT_URI ! ,
client_id: process . env . NOTION_CLIENT_ID ! ,
client_secret: process . env . NOTION_CLIENT_SECRET ! ,
})
console . log ( "Access token:" , tokenResponse . access_token )
console . log ( "Workspace:" , tokenResponse . workspace_name )
console . log ( "Bot ID:" , tokenResponse . bot_id )
Token Response
The OauthTokenResponse includes:
type OauthTokenResponse = {
access_token : string // Use this to make API requests
token_type : "bearer"
refresh_token : string | null // For refreshing expired tokens
bot_id : string // ID of the bot user
workspace_id : string
workspace_name : string | null
workspace_icon : string | null
owner : {
type : "user" | "workspace"
user ?: {
type : "person"
person : { email : string }
name : string | null
avatar_url : string | null
id : string
object : "user"
}
workspace ?: true
}
duplicated_template_id : string | null
request_id ?: string
}
Using the Access Token
Once you have the access token, use it to create an authenticated client:
const authedClient = new Client ({
auth: tokenResponse . access_token ,
})
const pages = await authedClient . search ({
query: "meeting notes" ,
})
Step 3: Refresh Token
When an access token expires, use the refresh token to get a new one:
Refresh Token Grant
const refreshedToken = await notion . oauth . token ({
grant_type: "refresh_token" ,
refresh_token: storedRefreshToken ,
client_id: process . env . NOTION_CLIENT_ID ! ,
client_secret: process . env . NOTION_CLIENT_SECRET ! ,
})
// Store the new access_token and refresh_token
console . log ( "New access token:" , refreshedToken . access_token )
Always store tokens securely. Never commit them to version control or expose them in client-side code.
Introspecting Tokens
Use oauth.introspect() to validate and inspect a token:
introspect() Method Signature
client . oauth . introspect (
args : OauthIntrospectParameters & {
client_id: string
client_secret : string
}
): Promise < OauthIntrospectResponse >
Example
const introspection = await notion . oauth . introspect ({
token: accessToken ,
client_id: process . env . NOTION_CLIENT_ID ! ,
client_secret: process . env . NOTION_CLIENT_SECRET ! ,
})
if ( introspection . active ) {
console . log ( "Token is valid" )
console . log ( "Issued at:" , new Date ( introspection . iat ! * 1000 ))
console . log ( "Scope:" , introspection . scope )
} else {
console . log ( "Token is invalid or expired" )
}
Introspect Response
type OauthIntrospectResponse = {
active : boolean // Whether the token is currently active
scope ?: string // Scopes granted to the token
iat ?: number // Issued at time (Unix timestamp)
request_id ?: string
}
Revoking Tokens
Revoke a token when a user disconnects your integration:
revoke() Method Signature
client . oauth . revoke (
args : OauthRevokeParameters & {
client_id: string
client_secret : string
}
): Promise < OauthRevokeResponse >
Example
await notion . oauth . revoke ({
token: accessToken ,
client_id: process . env . NOTION_CLIENT_ID ! ,
client_secret: process . env . NOTION_CLIENT_SECRET ! ,
})
console . log ( "Token revoked successfully" )
Revoke Response
type OauthRevokeResponse = {
request_id ?: string
}
After revoking a token, it can no longer be used to make API requests. The user will need to re-authorize your integration.
Complete OAuth Example
Here’s a complete Express.js example:
import express from "express"
import { Client } from "@notionhq/client"
const app = express ()
const notion = new Client ()
// Step 1: Redirect to Notion
app . get ( "/auth/notion" , ( req , res ) => {
const authUrl = new URL ( "https://api.notion.com/v1/oauth/authorize" )
authUrl . searchParams . append ( "client_id" , process . env . NOTION_CLIENT_ID ! )
authUrl . searchParams . append (
"redirect_uri" ,
"http://localhost:3000/auth/callback"
)
authUrl . searchParams . append ( "response_type" , "code" )
authUrl . searchParams . append ( "owner" , "user" )
res . redirect ( authUrl . toString ())
})
// Step 2: Handle callback
app . get ( "/auth/callback" , async ( req , res ) => {
const { code } = req . query
if ( ! code ) {
return res . status ( 400 ). send ( "No code provided" )
}
try {
// Exchange code for token
const tokenResponse = await notion . oauth . token ({
grant_type: "authorization_code" ,
code: code as string ,
redirect_uri: "http://localhost:3000/auth/callback" ,
client_id: process . env . NOTION_CLIENT_ID ! ,
client_secret: process . env . NOTION_CLIENT_SECRET ! ,
})
// Store tokens securely (database, encrypted session, etc.)
// req.session.notionAccessToken = tokenResponse.access_token
// req.session.notionRefreshToken = tokenResponse.refresh_token
res . send ( `
<h1>Success!</h1>
<p>Workspace: ${ tokenResponse . workspace_name } </p>
<p>Bot ID: ${ tokenResponse . bot_id } </p>
` )
} catch ( error ) {
console . error ( "OAuth error:" , error )
res . status ( 500 ). send ( "Authentication failed" )
}
})
app . listen ( 3000 , () => {
console . log ( "Server running on http://localhost:3000" )
})
Best Practices
Use state parameter for CSRF protection
Always include a random state parameter in the authorization URL and verify it in the callback to prevent CSRF attacks: // Generate and store state
const state = crypto . randomBytes ( 16 ). toString ( "hex" )
req . session . oauthState = state
authUrl . searchParams . append ( "state" , state )
// Verify in callback
if ( req . query . state !== req . session . oauthState ) {
throw new Error ( "Invalid state parameter" )
}
Never store tokens in localStorage or cookies accessible to JavaScript
Use encrypted database storage
Consider using environment variables for development only
Rotate tokens regularly
Handle token expiration gracefully
Implement automatic token refresh when API requests fail with authentication errors: try {
const response = await client . pages . retrieve ({ page_id: "id" })
return response
} catch ( error ) {
if ( error . code === "unauthorized" ) {
// Refresh token and retry
await refreshAccessToken ()
return await client . pages . retrieve ({ page_id: "id" })
}
throw error
}
Provide clear authorization UI
Explain what permissions your app needs
Show which workspace will be connected
Provide a way to disconnect/revoke access
Error Handling
Handle OAuth errors appropriately:
import { APIResponseError } from "@notionhq/client"
try {
const tokenResponse = await notion . oauth . token ({
grant_type: "authorization_code" ,
code: authCode ,
client_id: process . env . NOTION_CLIENT_ID ! ,
client_secret: process . env . NOTION_CLIENT_SECRET ! ,
})
} catch ( error ) {
if ( APIResponseError . isAPIResponseError ( error )) {
if ( error . code === "unauthorized" ) {
console . error ( "Invalid client credentials" )
} else if ( error . code === "validation_error" ) {
console . error ( "Invalid authorization code or parameters" )
}
}
throw error
}
Additional Resources
Notion OAuth Documentation Official Notion OAuth guide
OAuth 2.0 Specification Learn more about OAuth 2.0