12k
All articles

How to Add Sign in with GitHub

Add Sign in with GitHub using Node and Express. Build the OAuth flow with state and PKCE, exchange codes securely, fetch verified email, and refresh tokens.

OpenReplay Team
OpenReplay Team
How to Add Sign in with GitHub

To add Sign in with GitHub, you redirect the user to https://github.com/login/oauth/authorize, receive a temporary code at your callback URL, and exchange that code for an access token by POSTing to https://github.com/login/oauth/access_token from your server.

You have the OAuth app settings open in one tab and your route handler in another, and most tutorials hand the whole job to an SDK. This guide builds the server flow in Node using fetch and Express, with no OAuth library. For the underlying model, see All about OAuth 2.0. It covers state and PKCE, the null-email problem, scopes, and the redirect URI and token refresh changes GitHub released on August 14, 2026.

Key Takeaways

  • GitHub’s authorization code expires 10 minutes after it is issued, and the code exchange must run on your server because it requires the client secret.
  • Generate state on the server, store it in an httpOnly cookie, and abort the callback if the returned value differs. This blocks login CSRF.
  • GET https://api.github.com/user returns email: null when the user has no public email, so request user:email and use the primary, verified entry from GET /user/emails.
  • Since GitHub’s August 14, 2026 update, an OAuth app can register up to 10 redirect URIs, and new apps issue 8-hour access tokens with 6-month refresh tokens by default.

Register the GitHub OAuth App

Create an OAuth app under Settings → Developer settings → OAuth Apps, then copy the client ID and generate a client secret. GitHub’s registration guide lists every field.

Since the August 14, 2026 release, one OAuth app can hold up to 10 redirect URIs, which you add with the Add callback URL button. You can cover production, preview deployments and local development with a single app. For local work, register http://127.0.0.1:3000/auth/github/callback. localhost works, but GitHub’s loopback guidance points out that the OAuth RFC recommends the 127.0.0.1 literal.

Wildcard matching is now a setting on each redirect URI. If your app had just one callback URL before August 3, 2026, GitHub has turned wildcard matching on for it to keep the old redirect behavior. Turn it off unless you control every subdomain and path beneath that URL.

Redirect to GitHub’s Authorize Endpoint

The sign-in button points to a server route. That route generates state and a PKCE verifier, stores both in short-lived cookies, and redirects to GET https://github.com/login/oauth/authorize.

import crypto from 'node:crypto';
import express from 'express';
import cookieParser from 'cookie-parser';

const app = express();
app.use(cookieParser());

const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_REDIRECT_URI } = process.env;
// GITHUB_CLIENT_SECRET is read only here, on the server. Never ship it to the browser.
const isProd = process.env.NODE_ENV === 'production';

const tempCookie = {
  httpOnly: true,
  secure: isProd,
  sameSite: 'lax',
  maxAge: 10 * 60 * 1000, // matches the 10-minute code lifetime
  path: '/auth/github',
};

app.get('/auth/github', (req, res) => {
  const state = crypto.randomBytes(32).toString('base64url');
  const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');

  res.cookie('gh_state', state, tempCookie);
  res.cookie('gh_verifier', verifier, tempCookie);

  const params = new URLSearchParams({
    client_id: GITHUB_CLIENT_ID,
    redirect_uri: GITHUB_REDIRECT_URI, // one value per environment
    scope: 'read:user user:email',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });
  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

GitHub’s authorize parameters support one PKCE method, S256, and reject plain. The challenge has to be a 43-character SHA-256 value, which is exactly what base64url-encoding a SHA-256 digest gives you. If users often have more than one GitHub account, add prompt=select_account so GitHub always shows the account picker.

Validate state to Stop Login CSRF

The state parameter is a random value that no one can guess. Your server creates it and stores it in the session or an httpOnly cookie. When GitHub redirects back, compare it with the state on the callback. If the two differ, stop the sign-in and do not exchange the code.

Without that check, your app is open to login CSRF, an attack that signs the victim in to the attacker’s account:

  1. The attacker starts your GitHub sign-in flow with their own GitHub account.
  2. They approve access but stop before the callback runs, keeping their valid code.
  3. They get the victim’s browser to load https://yourapp.com/auth/github/callback?code=ATTACKER_CODE, for example through a hidden image or an auto-submitting link.
  4. Your server exchanges the code and signs the victim in as the attacker. Worse, if the victim was already logged in, it links the attacker’s GitHub identity to the victim’s account.
  5. Anything the victim then saves lands in an account the attacker controls. A linked identity also lets the attacker sign in as the victim later.

With state, step 4 fails. The victim’s browser either has no gh_state cookie or holds one that doesn’t match the attacker’s value, so the server rejects the request. PKCE adds a second binding: the code only works together with the code_verifier stored in the browser that started the flow. RFC 6749 defines state for this purpose, and the OAuth 2.0 Security Best Current Practice recommends PKCE for every client. The state and PKCE guidance in Securing OAuth best practices goes deeper.

function statesMatch(received, stored) {
  if (typeof received !== 'string' || typeof stored !== 'string') return false;
  const a = Buffer.from(received);
  const b = Buffer.from(stored);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The cookie attributes matter as well. HttpOnly keeps scripts away from the value, and Secure restricts it to HTTPS. SameSite=Lax lets the cookie survive GitHub’s top-level redirect back to you. A single gh_state cookie means a second sign-in tab overwrites the first, so show a clear retry message rather than a blank error.

Handle the Callback and Exchange the Code

The callback checks state, clears the temporary cookies, and POSTs the code to https://github.com/login/oauth/access_token with Accept: application/json. Without that header, GitHub’s token endpoint replies with a form-encoded body. The code expires 10 minutes after it is issued, so exchange it right away.

app.get('/auth/github/callback', async (req, res) => {
  const { code, state } = req.query;
  const { gh_state: savedState, gh_verifier: verifier } = req.cookies;

  res.clearCookie('gh_state', { path: '/auth/github' });
  res.clearCookie('gh_verifier', { path: '/auth/github' });

  if (typeof code !== 'string' || !verifier || !statesMatch(state, savedState)) {
    return res.status(400).send('Sign-in failed. Please try again.');
  }

  const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      client_id: GITHUB_CLIENT_ID,
      client_secret: GITHUB_CLIENT_SECRET,
      code,
      redirect_uri: GITHUB_REDIRECT_URI, // must match the authorize request
      code_verifier: verifier,
    }),
  });
  const token = await tokenRes.json();
  if (token.error || !token.access_token) {
    return res.status(400).send('Sign-in failed. Please try again.');
  }

  const profile = await getGitHubProfile(token.access_token);
  const user = await upsertUserByGitHubId(profile); // key on profile.id
  await saveGitHubTokens(user.id, token);           // server-side only
  await createSession(res, user.id);                // your own session cookie
  res.redirect('/');
});

Keep this exchange on the server, because it needs the client secret and that secret must never reach the browser. Once the exchange succeeds, issue your own session cookie and keep the GitHub token server-side. Cookies vs localStorage for JWT auth covers the storage trade-offs. The final res.redirect('/') removes code and state from the address bar, so a Back press can’t replay a spent code.

Session replays of failed GitHub sign-ins show the full sequence: click, GitHub, callback, error page. That sequence separates state mismatches from expired codes. Configure any replay, analytics or logging tool to mask the code and state query parameters.

Fetch the User and a Verified Email

Call GET https://api.github.com/user with the token to identify the user. The authenticated-user endpoint returns email: null when the user hasn’t set a public email. OAuth tokens also need read:user to get the private user response.

const ghHeaders = (accessToken) => ({
  Authorization: `Bearer ${accessToken}`,
  Accept: 'application/vnd.github+json',
  'User-Agent': 'your-app-name',
});

async function getGitHubProfile(accessToken) {
  const userRes = await fetch('https://api.github.com/user', { headers: ghHeaders(accessToken) });
  if (!userRes.ok) throw new Error(`GET /user failed: ${userRes.status}`);
  const user = await userRes.json();

  let email = user.email;
  if (!email) {
    const emailsRes = await fetch('https://api.github.com/user/emails', { headers: ghHeaders(accessToken) });
    if (emailsRes.ok) {
      const emails = await emailsRes.json();
      email = emails.find((e) => e.primary && e.verified)?.email ?? null;
    }
  }
  return { id: user.id, login: user.login, name: user.name, email };
}

With the user:email scope, GET /user/emails returns every address along with primary and verified flags. Pick the entry where both are true, and never link accounts on an unverified address. Key users on the numeric id, because logins and emails can change. GitHub’s REST API refuses any request that has no User-Agent header, so set one yourself. GitHub also advises checking who the user is each time you get a new token, since the user may have switched accounts on the authorize page.

Request the Least Scope You Need

Sign in with GitHub needs only two OAuth scopes: read:user and user:email. GitHub’s scope reference defines each one, and users see every scope you ask for on GitHub’s consent screen.

You needScope
Profile: id, login, name, avatarread:user
Email addressesuser:email
Repository contentsrepo (full access, including private repos)

Requesting repo for sign-in asks users to hand over read/write access to their private code, and many will stop at that point. If you need repository access for a later feature, run a separate flow at that point. GitHub allows at most ten tokens for each user, app and scope combination, and no more than ten new tokens per hour. If a callback loop hits that hourly limit, GitHub stops and shows the user a re-authorization prompt.

Refresh Expiring Tokens

To refresh a GitHub token, POST grant_type=refresh_token to the token endpoint. Using a refresh token retires it along with the old access token, so save the new pair before making another call. With expiring tokens on, access tokens expire after eight hours, and an unused refresh token expires after six months. Since the August 14, 2026 change, new apps get this behavior by default. Existing apps can opt in for a single sign-in by adding the offline_access scope, which doesn’t add a consent prompt, or they can enable it in the app settings.

async function refreshGitHubToken(refreshToken) {
  // No refresh token means a non-expiring token: nothing to refresh.
  if (!refreshToken) return null;

  const res = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      client_id: GITHUB_CLIENT_ID,
      client_secret: GITHUB_CLIENT_SECRET,
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
    }),
  });
  const data = await res.json();
  if (data.error === 'bad_refresh_token') return { reauth: true };
  if (data.error) throw new Error(data.error);

  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };
}

You can’t change scopes during a refresh. bad_refresh_token means the user has to sign in again. Don’t assume a refresh_token comes back at all: some GitHub Enterprise Server versions ignore offline_access and issue a non-expiring token.

Should You Use an OAuth App or a GitHub App?

For Sign in with GitHub alone, an OAuth app requesting read:user user:email is enough, even though GitHub recommends GitHub Apps over OAuth apps in general. GitHub’s comparison page covers the full differences.

OAuth appGitHub App
Permission modelBroad scopesFine-grained permissions
Repository selectionAll repos the scope coversUser picks repositories
Token lifetime8-hour expiring by default for new apps; long-lived for older apps unless enabledShort-lived
GitHub’s recommendationSupportedPreferred

Choose a GitHub App when your product acts on repositories. For identity, the OAuth app flow above is enough.

Security Checklist

  • Send state and PKCE S256 on every request, and verify both on the callback.
  • Send the same redirect_uri in the authorize request and the token request.
  • Disable wildcard matching you don’t need.
  • Keep the client secret and GitHub tokens on the server. Give the browser only your own session cookie.
  • Redirect away from the callback after the exchange.
  • Handle a token response that has no refresh_token.

Sign in with GitHub comes down to three requests: authorize, exchange, fetch the user. The security lives in the details around them: a server-held state, a PKCE verifier, a secret that never leaves your backend, and an email you’ve confirmed is verified. Next, open your OAuth app settings, add your preview and 127.0.0.1 callback URLs, turn off wildcard matching, and ship the three routes above.

FAQs

Why does GitHub's token endpoint return bad_verification_code?

GitHub returns bad_verification_code when the code you send is incorrect, expired, or does not match the one issued in the authorization request. The usual causes are a code older than 10 minutes, or a code that was already exchanged, for example after a Back press or a second request to the callback. You cannot recover that code. Restart the sign-in flow to get a new one.

What causes redirect_uri_mismatch in GitHub OAuth?

GitHub returns redirect_uri_mismatch when the redirect_uri you send does not match a callback URL registered on the OAuth app. Send a registered URL exactly, or leave the parameter out and GitHub uses the app's default. Send the same value on the authorize request and the token request. GitHub treats 127.0.0.1 and localhost as different hosts, so register the one your local server uses.

Does signing out of my app revoke the user's GitHub token?

No. Ending your own session leaves the GitHub token and the authorization valid. To revoke one token, call DELETE /applications/{client_id}/token. To remove every token and the app's authorization for that user, call DELETE /applications/{client_id}/grant. Both endpoints use Basic authentication with the client_id and client_secret, plus the access_token in the request body. Users can also revoke access in their GitHub account settings.

Can a non-expiring GitHub OAuth token still stop working?

Yes. GitHub automatically revokes an OAuth token that goes unused for one year. A token also stops working when the user revokes your app's authorization, or when the token is pushed to a public repository or gist. If one user, app and scope combination collects more than ten tokens, GitHub revokes one of the older ones. When a stored token stops working, send the user through sign-in again.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.