How to Implement Login with GitHub Safely

A practical server-side GitHub OAuth login flow using state validation, authorization code exchange, GitHub user lookup, and a local session.

Many websites no longer build a complete username and password system from scratch. They use third-party login instead. For developer tools, technical communities, and open source project dashboards, GitHub login is a common choice.

But “implement Login with GitHub” is sometimes misunderstood as “let the frontend obtain a GitHub access token and store it in the browser.” That can make a demo work, but it is not a good default for a real application.

A safer structure is: the browser handles redirects, while the server validates state, exchanges the authorization code for a token, fetches the GitHub user identity, and creates the application’s own login session. The frontend receives your site’s session, not the GitHub token.

Chinese version of this article

OAuth Flow Responsibilities

GitHub OAuth App’s web application flow has three main steps:

  1. The user is redirected from your site to GitHub’s authorization page.
  2. After authorization, GitHub redirects back to your callback URL with a temporary code and the state value.
  3. Your server exchanges the code for an access token, then uses that token to call the GitHub API and identify the user.

Two details are critical:

  • client_secret belongs only on the server.
  • state must be validated to protect against CSRF and mixed-up login sessions.

If the only goal is to let users sign in with their GitHub identity, an OAuth App is usually enough. If you need fine-grained repository permissions, organization installation, or automation as an app identity, evaluate GitHub Apps first.

Create a GitHub OAuth App

In GitHub, open:

Settings -> Developer settings -> OAuth Apps -> New OAuth App

Fill in the key fields:

  • Application name: the app name.
  • Homepage URL: your site homepage.
  • Authorization callback URL: for example, https://example.com/auth/github/callback.

After creation, GitHub gives you:

  • Client ID: safe to include in the authorization URL.
  • Client Secret: server-only, usually stored in environment variables or a secret manager.

For local development, the callback URL can be:

http://localhost:3000/auth/github/callback

Use HTTPS in production.

Recommended Architecture

A clean login flow looks like this:

Browser clicks Login with GitHub
  -> server generates state and code_verifier
  -> server stores state/code_verifier in an HttpOnly temporary cookie or session
  -> server redirects to GitHub authorization page
  -> GitHub redirects to /auth/github/callback?code=...&state=...
  -> server validates state
  -> server exchanges code for access token
  -> server requests GitHub /user and /user/emails
  -> server creates or updates local user
  -> server writes local session cookie
  -> browser returns to the application page

PKCE can be used here too. GitHub’s documentation now strongly recommends code_challenge and code_verifier. Even when a server-side application already has a client_secret, PKCE still reduces the risk if an authorization code is intercepted.

Start the Authorization Request

The example below uses Express. In a real project, temporary OAuth state can live in Redis, a database session, or an encrypted cookie.

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

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

const clientId = process.env.GITHUB_CLIENT_ID;
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
const redirectUri = 'http://localhost:3000/auth/github/callback';
const isProduction = process.env.NODE_ENV === 'production';

function base64url(buffer) {
  return buffer
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

function createCodeChallenge(verifier) {
  return base64url(crypto.createHash('sha256').update(verifier).digest());
}

app.get('/auth/github/start', (req, res) => {
  const state = base64url(crypto.randomBytes(32));
  const codeVerifier = base64url(crypto.randomBytes(32));
  const codeChallenge = createCodeChallenge(codeVerifier);

  res.cookie('github_oauth_state', state, {
    httpOnly: true,
    secure: isProduction,
    sameSite: 'lax',
    maxAge: 10 * 60 * 1000,
  });

  res.cookie('github_oauth_code_verifier', codeVerifier, {
    httpOnly: true,
    secure: isProduction,
    sameSite: 'lax',
    maxAge: 10 * 60 * 1000,
  });

  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: 'read:user user:email',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

Keep scopes small. For login identity, common scopes are:

  • read:user: read basic user profile data.
  • user:email: read user email addresses, especially when the profile-level email field is empty.

Do not request high-permission scopes such as repo just for login. Larger scopes make users more cautious and increase the damage if a token leaks.

Handle the GitHub Callback

GitHub redirects back with code and state. The server must first compare the returned state with the value it stored earlier. If they do not match, abort the flow.

app.get('/auth/github/callback', async (req, res) => {
  const { code, state } = req.query;

  if (!code || !state) {
    return res.status(400).send('Missing OAuth code or state');
  }

  if (state !== req.cookies.github_oauth_state) {
    return res.status(400).send('Invalid OAuth state');
  }

  const tokenResponse = 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: clientId,
      client_secret: clientSecret,
      code: String(code),
      redirect_uri: redirectUri,
      code_verifier: req.cookies.github_oauth_code_verifier,
    }),
  });

  const tokenData = await tokenResponse.json();

  if (!tokenResponse.ok || tokenData.error) {
    return res.status(401).json({
      message: 'GitHub authorization failed',
      error: tokenData.error,
    });
  }

  const accessToken = tokenData.access_token;

  const githubUser = await fetchGitHubUser(accessToken);
  const emails = await fetchGitHubEmails(accessToken);

  const primaryEmail =
    emails.find((email) => email.primary && email.verified)?.email ??
    githubUser.email;

  const user = await upsertUserFromGitHub({
    githubId: githubUser.id,
    login: githubUser.login,
    name: githubUser.name,
    avatarUrl: githubUser.avatar_url,
    email: primaryEmail,
  });

  const sessionId = await createSession(user.id);

  res.clearCookie('github_oauth_state');
  res.clearCookie('github_oauth_code_verifier');
  res.cookie('session_id', sessionId, {
    httpOnly: true,
    secure: isProduction,
    sameSite: 'lax',
  });

  res.redirect('/dashboard');
});

Use Authorization: Bearer when calling the GitHub API:

async function fetchGitHubUser(accessToken) {
  const response = await fetch('https://api.github.com/user', {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      Accept: 'application/vnd.github+json',
    },
  });

  if (!response.ok) {
    throw new Error('Failed to fetch GitHub user');
  }

  return response.json();
}

async function fetchGitHubEmails(accessToken) {
  const response = await fetch('https://api.github.com/user/emails', {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      Accept: 'application/vnd.github+json',
    },
  });

  if (!response.ok) {
    return [];
  }

  return response.json();
}

upsertUserFromGitHub() and createSession() depend on your own application. Common practices are:

  • Bind local users by GitHub user id, not only by login. A login can change; the id is more stable.
  • Store display fields such as avatar, name, and email.
  • Use your own session or JWT system for your site.
  • Do not store the GitHub token long term if you do not need to call GitHub APIs later.

What the Frontend Should Do

The frontend only needs to send users to the server-side login entry:

<a href="/auth/github/start">Continue with GitHub</a>

Or redirect on button click:

document.querySelector('#github-login').addEventListener('click', () => {
  window.location.href = '/auth/github/start';
});

The frontend does not need to know client_secret, and it should not store the GitHub access token in localStorage. The browser should hold only your application’s own session cookie.

Common Pitfalls

Skipping state Validation

state is easy to omit and should not be omitted. It should be an unguessable random string tied to the login attempt. If the callback value does not match, abort the flow.

Returning the Token to the Frontend

A GitHub access token represents user authorization. Returning it to the frontend and storing it in localStorage increases the damage of an XSS issue. Unless the application is intentionally designed as a pure frontend OAuth client, prefer server-held tokens and browser-held local sessions.

Using login as the Only Identifier

GitHub usernames can change. Prefer GitHub user id when binding accounts in your database.

Requesting Too Many Scopes

Login usually does not require repository access. Larger scopes make the authorization page look more sensitive and make user trust harder to earn.

Assuming email Is Always Present

The email field on the GitHub user profile can be empty. If your application needs email, request user:email, call /user/emails, and prefer a verified primary email.

Conclusion

The core of GitHub login is not building an authorization URL in the frontend. It is putting the OAuth security boundary in the right place:

  • The frontend redirects.
  • The server stores client_secret.
  • state binds the login request to the callback.
  • The authorization code is exchanged on the server.
  • The token is used to validate the user’s GitHub identity.
  • Your own application session manages the logged-in state.

With this structure, GitHub is the identity provider, while your application still owns its account system.

Further Reading