How to Use MCP OAuth to Connect Claude to Third-Party APIs Securely

Learn how MCP's OAuth support lets you connect Claude to real third-party APIs without hardcoding credentials or compromising security.

Why OAuth Matters for MCP (And Why I Got Burned Ignoring It)

When I first started building MCP servers, I did what most beginners do: I pasted my API keys directly into my config file. GitHub token? Right there in plain text. Notion integration secret? Hardcoded. It worked, sure — until I accidentally committed that config file to a public repo. Within 45 minutes, GitHub had auto-revoked my token and sent me a scary email. Fun times.

If you're connecting Claude to real third-party services through MCP — GitHub, Notion, Slack, Google APIs, whatever — hardcoding credentials is a ticking clock. OAuth is the proper solution, and the good news is that MCP now has solid OAuth support built in. This guide walks you through how it actually works and how to set it up without losing your mind.

What MCP OAuth Actually Does

Let's clear up the terminology first, because "OAuth" gets thrown around loosely. OAuth 2.0 is an authorization framework that lets a user grant an application limited access to their account on another service — without handing over their password. You've seen this a thousand times: "Sign in with Google," "Authorize with GitHub," etc.

In the MCP context, OAuth means your MCP server can request a temporary access token on behalf of the user, rather than requiring you to bake in a static API key. The flow looks like this:

  1. Claude calls your MCP server and needs to access a third-party API
  2. Your MCP server detects there's no valid token, triggers an OAuth flow
  3. The user authenticates with the third-party service (GitHub, Notion, etc.)
  4. Your MCP server gets a short-lived access token and uses it for the API call
  5. The token is stored securely and refreshed when needed

No static keys sitting in config files. No credentials in your git history. Much better.

Setting Up an OAuth App on GitHub (The Real Example)

Let's make this concrete. We'll connect an MCP server to GitHub's API using OAuth. First, you need to register an OAuth application with GitHub.

Head to GitHub → Settings → Developer Settings → OAuth Apps → New OAuth App. Fill in:

  • Application name: Something like "My MCP Server"
  • Homepage URL: http://localhost:3000 for local dev
  • Authorization callback URL: http://localhost:3000/oauth/callback

After saving, GitHub gives you a Client ID and lets you generate a Client Secret. Store these in a .env file — not in your server code directly.

.env
# Never commit this file to git
GITHUB_CLIENT_ID=Ov23liYOURCLIENTID
GITHUB_CLIENT_SECRET=abc123yoursecrethere
OAUTH_REDIRECT_URI=http://localhost:3000/oauth/callback
SESSION_SECRET=some-long-random-string-here

And add .env to your .gitignore immediately. Seriously, do it before anything else.

Building the OAuth Handler in Your MCP Server

Now let's wire this up. I'll show a Node.js/TypeScript example since that's the most common MCP server setup. You'll need a few packages:

terminal
npm install @modelcontextprotocol/sdk express express-session dotenv node-fetch
→ added 47 packages

Here's the core OAuth flow for your MCP server. This handles both the initial redirect and the callback:

oauth-handler.ts
// OAuth initiation — redirect user to GitHub
app.get('/oauth/github', (req, res) => {
  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID!,
    redirect_uri: process.env.OAUTH_REDIRECT_URI!,
    scope: 'repo read:user',
    state: generateRandomState() // CSRF protection
  });
  req.session.oauthState = params.get('state');
  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

// Callback — exchange code for token
app.get('/oauth/callback', async (req, res) => {
  const { code, state } = req.query;
  if (state !== req.session.oauthState) {
    return res.status(403).send('State mismatch — possible CSRF');
  }
  const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: { Accept: 'application/json' },
    body: new URLSearchParams({
      client_id: process.env.GITHUB_CLIENT_ID!,
      client_secret: process.env.GITHUB_CLIENT_SECRET!,
      code: code as string,
    })
  });
  const { access_token } = await tokenResponse.json();
  req.session.githubToken = access_token;
  res.send('Authenticated! You can close this tab.');
});

Always Validate the State Parameter

That state parameter isn't optional decoration — it's your CSRF protection. If you skip it, an attacker can trick users into authorizing a token that the attacker controls. Two extra lines of code, huge security win.

Wiring the Token into Your MCP Tool

Once you have a token stored in the session, your MCP tools can use it for authenticated API calls. Here's how a GitHub tool actually uses the token at call time:

github-tool.ts
server.tool(
  'list_my_repos',
  { description: 'List your GitHub repositories' },
  async (args, context) => {
    const token = context?.session?.githubToken;
    if (!token) {
      return {
        content: [{ type: 'text', text: 'Not authenticated. Visit /oauth/github first.' }]
      };
    }
    const repos = await fetch('https://api.github.com/user/repos', {
      headers: { Authorization: `Bearer ${token}` }
    }).then(r => r.json());
    return {
      content: [{ type: 'text', text: repos.map(r => r.full_name).join(' ') }]
    };
  }
);

The key pattern here: always check for the token first and return a helpful message if it's missing. Claude will relay that message to the user, who can then go through the auth flow. It makes the whole experience feel intentional rather than broken.

Token Storage: What to Do in Production

Sessions in memory work fine for local development, but in any real deployment you need persistent, encrypted token storage. Here's my hierarchy of options:

  • Local dev: In-memory session (fine, tokens die on restart)
  • Single-user MCP server: Encrypted file (use something like keytar for OS keychain access)
  • Multi-user deployment: Redis or a database with encrypted token columns

For most of us running MCP servers locally for our own Claude setup, the OS keychain approach is the sweet spot. keytar lets you store and retrieve secrets from macOS Keychain, Windows Credential Manager, or libsecret on Linux without any extra infrastructure.

terminal
npm install keytar

# Store a token
await keytar.setPassword('my-mcp-server', 'github-token', access_token);

# Retrieve it later
const token = await keytar.getPassword('my-mcp-server', 'github-token');

Request Only the Scopes You Need

When setting up your OAuth app, resist the urge to request broad permissions "just in case." If your MCP server only reads repos, don't request write access. Minimal scopes mean less damage if a token is ever compromised — and it's a good habit that third-party services will thank you for.

Applying This Pattern to Other APIs

The GitHub example above is really just a template. The same pattern applies to almost any OAuth 2.0 service:

  • Notion: Uses OAuth 2.0, redirect to https://api.notion.com/v1/oauth/authorize
  • Slack: Same flow, different endpoints, returns a bot token
  • Google APIs: Slightly more complex because of refresh tokens and token expiry, but same core idea
  • Linear: OAuth 2.0 with a nearly identical callback structure to GitHub

The only real wrinkle is refresh tokens. Some APIs (Google especially) give you a short-lived access token and a long-lived refresh token. You need to store both and implement a refresh flow that automatically gets a new access token when the current one expires. It's more code, but the pattern is well-documented in each API's docs.

When I set up my Notion MCP integration this way, I went from "copy-paste API key every 90 days" to a completely automated flow that just works. Claude asks Notion for data, the server handles auth silently, and I never touch credentials again. That's the payoff.

Quick Checklist Before You Ship

Before you call your OAuth MCP integration done, run through this list:

  • ✅ .env is in .gitignore
  • ✅ State parameter is validated on every callback
  • ✅ OAuth scopes are minimal — only what you actually use
  • ✅ Tools return a friendly "please authenticate" message when no token exists
  • ✅ Tokens are stored outside of memory for anything beyond local dev
  • ✅ Client secret is never exposed to the browser or Claude's context

OAuth feels intimidating the first time — there's a lot of vocabulary and a few moving parts. But once you've wired it up once, the pattern clicks. And honestly, the peace of mind of never worrying about leaked API keys is worth every minute of setup time. Trust me on this one.

Keep going

More tutorials in this category, or explore the full field guide.

More Plugin Tutorials Official Docs ↗