← Back to Blog

How to Connect Claude Code to GitHub (with OAuth)

Casey Handler·

🤖 AI Agent? You can point your AI agent at this article and it can follow the steps to connect automatically. Everything it needs is here: the API endpoints, request/response formats, scopes, and working code samples. The TapAuth API is two calls — POST https://tapauth.ai/api/v1/grants to create a grant, then GET https://tapauth.ai/api/v1/token/{id} (with Bearer auth) to retrieve the access token. No API key needed — your agent can follow the code examples below end-to-end.

Claude Code can write functions, refactor entire modules, and reason about complex codebases — but the moment it needs to open a pull request, read issue comments, or check CI status, it needs a GitHub token. GitHub's OAuth system is well-documented, but it still means running a redirect server, managing client secrets, and handling token expiration. For an AI agent that just wants to push a branch, that's a lot of plumbing.

This is exactly the kind of problem TapAuth was built to solve. TapAuth is the token layer for AI agents — one API call, and your agent gets a valid, scoped, user-approved GitHub token. No OAuth implementation. No redirect servers. No client secrets in your agent code. Your agent asks for a token and gets one.

This guide walks through the manual GitHub OAuth flow (so you understand what you're skipping) and then the TapAuth approach. GitHub is one of the more standard OAuth providers, but there's still enough complexity to justify never building it yourself.

Why GitHub OAuth is trickier than it looks for agents

GitHub's OAuth implementation is relatively clean by OAuth standards, but it still has several gotchas that trip up agent developers:

  • Two OAuth flows, and agents can't use either directly. GitHub supports the standard web flow (with redirects) and a device flow (for CLIs). The web flow requires a browser redirect, which an AI agent can't initiate on its own. The device flow is better for agents but adds polling complexity and a manual user step.
  • Fine-grained vs. classic tokens. GitHub has been migrating from classic OAuth tokens to fine-grained personal access tokens. OAuth apps still issue classic tokens, but GitHub Apps issue installation tokens with different permission models. Choosing the wrong token type means you either get too much access or not enough.
  • Tokens expire — but not consistently. GitHub OAuth tokens from the web flow don't expire by default. But if you enable token expiration (which GitHub now recommends), you get refresh tokens with a 6-month lifetime. GitHub App installation tokens expire after 1 hour. Your agent needs different refresh logic depending on which token type it has.
  • Organization access requires separate approval. Even with a valid user token, accessing organization repos requires the org admin to approve your OAuth app. Your agent might get a token that works for personal repos but 404s on org repos — with no clear error message explaining why.
  • Scope granularity has gaps. The repo scope grants full access to all repositories — public and private. There's no way to scope a classic OAuth token to a single repo. Fine-grained tokens can do this, but they use a completely different authorization model.

None of these are insurmountable. But each one is a few hours of reading docs, writing edge-case handling code, and debugging token behavior that differs from every other OAuth provider your agent talks to.

The manual GitHub OAuth flow

Here's what the web-based OAuth flow looks like for GitHub. This gets you a token — but you need a running web server to receive the callback.

import express from 'express';

const app = express();

const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID!;
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET!;
const REDIRECT_URI = 'http://localhost:3000/github/callback';

// Step 1: Redirect user to GitHub's authorization page
app.get('/github/auth', (req, res) => {
  const params = new URLSearchParams({
    client_id: GITHUB_CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'repo read:org',
    state: crypto.randomUUID(), // CSRF protection
  });
  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

// Step 2: Handle the callback
app.get('/github/callback', async (req, res) => {
  const code = req.query.code as string;
  const state = req.query.state as string;

  if (!code) {
    res.status(400).send('No authorization code received');
    return;
  }

  // Step 3: Exchange code for access token
  // Important: GitHub returns tokens as form-encoded by default,
  // so you must request JSON explicitly
  const tokenResponse = await fetch(
    'https://github.com/login/oauth/access_token',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json', // Without this, GitHub returns form-encoded
      },
      body: JSON.stringify({
        client_id: GITHUB_CLIENT_ID,
        client_secret: GITHUB_CLIENT_SECRET,
        code,
        redirect_uri: REDIRECT_URI,
      }),
    }
  );

  const data = await tokenResponse.json();

  if (data.error) {
    console.error('GitHub OAuth error:', data.error_description);
    res.status(500).send(`Auth failed: ${data.error_description}`);
    return;
  }

  // Step 4: Store the token
  // If token expiration is enabled, you'll also get:
  // data.refresh_token, data.expires_in, data.refresh_token_expires_in
  const accessToken = data.access_token;
  const refreshToken = data.refresh_token; // may be undefined

  await storeToken({ accessToken, refreshToken });

  res.send('Connected to GitHub!');
});

// Step 5: Use the token
async function createPullRequest(
  owner: string,
  repo: string,
  title: string,
  head: string,
  base: string
) {
  const { accessToken } = await getStoredToken();

  const response = await fetch(
    `https://api.github.com/repos/${owner}/${repo}/pulls`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
      },
      body: JSON.stringify({ title, head, base }),
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`GitHub API error: ${error.message}`);
  }

  return response.json();
}

app.listen(3000);

It looks cleaner than Slack's implementation, but the complexity is hiding. You still need the redirect server, the client secret management, the CSRF state parameter, the explicit Accept: application/json header (without which GitHub returns URL-encoded data from a JSON endpoint), and the refresh token logic if expiration is enabled. For an AI agent, that's a full OAuth stack to maintain alongside the actual GitHub work.

The device flow alternative

GitHub also supports a device authorization flow that works better for CLI tools and agents. Instead of redirecting a browser, it displays a code the user enters at github.com/login/device:

// Device flow — no redirect server needed, but polling is required
async function deviceFlowAuth() {
  // Step 1: Request a device code
  const codeResponse = await fetch(
    'https://github.com/login/device/code',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({
        client_id: GITHUB_CLIENT_ID,
        scope: 'repo read:org',
      }),
    }
  );

  const { device_code, user_code, verification_uri, interval } =
    await codeResponse.json();

  console.log(`Go to ${verification_uri} and enter code: ${user_code}`);

  // Step 2: Poll until the user completes authorization
  while (true) {
    await new Promise((r) => setTimeout(r, (interval + 1) * 1000));

    const tokenResponse = await fetch(
      'https://github.com/login/oauth/access_token',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json',
        },
        body: JSON.stringify({
          client_id: GITHUB_CLIENT_ID,
          device_code,
          grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
        }),
      }
    );

    const data = await tokenResponse.json();

    if (data.access_token) {
      return data.access_token;
    }

    if (data.error === 'authorization_pending') {
      continue; // User hasn't entered the code yet
    }

    if (data.error === 'slow_down') {
      await new Promise((r) => setTimeout(r, 5000)); // Back off
      continue;
    }

    throw new Error(`Device flow error: ${data.error}`);
  }
}

The device flow is genuinely better for agents, but it still means implementing polling logic, handling slow-down responses, managing the client ID, and building timeout handling for users who never complete the flow. It's also OAuth-app-only — GitHub Apps use a different installation flow entirely.

The TapAuth approach: one API call, any provider

TapAuth is an access gateway for AI agents. It handles the entire GitHub OAuth flow — web flow, device flow, token storage, refresh — and gives your agent the same simple interface it uses for every other provider.

// Step 1: Create a grant — tell TapAuth what you need
const grantResponse = await fetch(
  'https://tapauth.ai/api/v1/grants',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.TAPAUTH_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      provider: 'github',
      scopes: ['repo', 'read:org'],
    }),
  }
);
const { grant_id, grant_secret, approve_url } = await grantResponse.json();

// Step 2: The user approves at the approve_url
// (Show this link to the user — they authorize once)
console.log(`Approve access: ${approve_url}`);

// Step 3: Retrieve the token (poll until approved)
const tokenResponse = await fetch(
  `https://tapauth.ai/api/v1/token/${grant_id}`,
  {
    headers: { 'Authorization': `Bearer ${grant_secret}` },
  }
);
const { access_token } = await tokenResponse.json();

// Now use the token directly with GitHub's API
const prResponse = await fetch(
  'https://api.github.com/repos/myorg/myrepo/pulls',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${access_token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
    },
    body: JSON.stringify({
      title: 'Refactor auth module',
      head: 'feature/auth-refactor',
      base: 'main',
    }),
  }
);
const pullRequest = await prResponse.json();

Same two API calls as connecting to Slack, Gmail, or Google Drive. TapAuth manages the OAuth flow, stores the token securely, and handles refresh when tokens expire. Claude Code doesn't need to know whether GitHub uses web flow or device flow, whether tokens expire or not, or whether org access requires separate approval. It gets a working token and uses the GitHub API directly.

That's the real value: your agent's GitHub integration code is just GitHub API calls. The authentication layer is completely abstracted.

What you can build with Claude Code + GitHub + TapAuth

With GitHub authentication handled, Claude Code becomes a full participant in your development workflow:

  • Autonomous PR creation: Claude Code writes the code, creates the branch, pushes commits, and opens the pull request — all through GitHub's API with a scoped token. No SSH keys shared with the agent.
  • Issue triage: "Read the last 20 issues, categorize them by component, and add labels." Claude Code can read issue bodies, analyze them, and update labels and assignees via the API.
  • Code review assistant: "Review the open PRs in this repo and post comments on anything that looks off." Claude Code reads diffs, analyzes changes, and posts review comments — all through authenticated API calls.
  • CI/CD monitor: "Check the status of the latest workflow runs and summarize any failures." Claude Code reads Actions workflow results and translates CI logs into actionable summaries.

Each of these requires a GitHub token with appropriate scopes. Without TapAuth, each also requires building and maintaining OAuth infrastructure. With TapAuth, you write the GitHub API calls and nothing else.

Scopes and permissions: what to request for GitHub

GitHub's classic OAuth scopes are coarse-grained. Request only what your agent actually needs:

ScopeWhat it allowsWhen to use it
repoFull access to public and private reposPR creation, code push, issue management
read:orgRead org membership and teamsWhen accessing org repos or team info
workflowUpdate GitHub Actions workflowsCI/CD management, workflow dispatch
read:userRead user profile dataIdentity verification, user context
gistCreate and read gistsSharing code snippets, scratch storage

For most Claude Code use cases, repo alone is sufficient. It covers reading and writing code, managing issues, creating PRs, and accessing Actions status. Add read:org if your agent works across organization repositories.

The repo scope is broad — it grants access to all repositories the user can see. If you need finer control (single-repo access, read-only code), consider using GitHub's fine-grained tokens through TapAuth instead. TapAuth can request the minimum permissions your agent needs, regardless of which GitHub token format is under the hood.

Stop building OAuth infrastructure for GitHub

GitHub is one of the better OAuth providers, but "better" still means redirect servers, client secret management, token refresh logic, and org access edge cases. Every hour you spend on GitHub auth plumbing is an hour Claude Code isn't spending on actual development work.

TapAuth reduces GitHub authentication to one API call. The same API call you'd use for Slack, Gmail, Google Drive, or any other OAuth provider. Two calls total: create a grant, retrieve the token. Done.

Get started — connect Claude Code to GitHub in under five minutes. No client secrets in your agent code. No redirect servers. Just tokens when your agent needs them.