Inspect GitHub OAuth 2.0

Build a local GitHub OAuth client with PKCE, state checks, and profile access.

Introduction

30 Second Summary

A “Sign in with GitHub” button makes account access feel instant. Behind that click, your browser makes a round trip that an app must verify before trusting the result.

In this project, you will build a local OAuth Flow Inspector with Express and GitHub OAuth Apps. The finished flow rejects untrusted callbacks before showing your GitHub profile without revealing the access token.

What You'll Build

You will click Authorize with GitHub and return to a clean result page showing your avatar, account details, granted scope, and token type.

By the end of this project, you'll have:

  • An authorization dashboard that starts a real Authorization Code flow through GitHub before returning to your local callback.
  • A visible transaction failure that proves why a returned authorization code needs its original browser context.
  • A protected profile result showing your avatar, login, public repository count, granted scope, and token type while the raw access token stays on the server.
  • Secret Mission: Forge a callback query and prove the app rejects the wrong state before any token exchange.

Are there any prerequisites?

You'll need a GitHub account, Visual Studio Code, and Node.js 22.21.0 or newer. Comfort with JavaScript and web APIs will help you follow the server code.

Before We Start

This opening checkpoint locks in what your inspector reveals before any hands-on work begins. You are building a local OAuth flow where a GitHub access token authorizes delegated profile requests only after the callback validates browser-bound state and recovers the matching PKCE verifier.

Set Up the Local Inspector

The redirect flow you are about to build needs a real local destination. A visible OAuth Flow Inspector makes each later browser handoff observable.

Node.js runs the local server. Express handles requests from your browser.

Visual Studio Code keeps the project files together while you build the inspector.

In this step, get ready to:
  • Verify that Node.js meets the project's minimum version.
  • Prepare a Visual Studio Code workspace with Express 5.3.0.
  • Run the starter dashboard on your local server.
Verify Node.js and npm

Node.js executes the server code on your Mac. The project needs version 22.21.0 or newer because its final server uses a stable environment-loading API.

The Node.js installer also includes npm. You use npm to install the exact Express dependency recorded in package.json.

  • Open Spotlight by pressing Cmd+Space.
  • Type Terminal.
  • Press Return to open Terminal.
  • Check both installed tools by running:
node --version
npm --version

What do these checks prove?

  • The first command reports the Node.js version available to your shell.
  • The second command confirms that npm is installed alongside Node.js.

✔️ I see version 22.21.0 or higher

Your runtime meets the project requirement. That clears the first setup hurdle.

A version newer than 22.21.0 is compatible with this project.

ⓧ I see an older version

Your current Node.js version is below 22.21.0. The current LTS installer upgrades Node.js while installing a compatible npm version.

  • Visit the official Node.js download page.
  • Download the macOS Installer for the current LTS release.
  • Open the downloaded .pkg file.
  • Complete the installer prompts.
  • Return to Terminal.
  • Verify the upgraded tools by running:
node --version
npm --version

What should you see?

The first value should now be 22.21.0 or higher. The second line should show an npm version number.

Still seeing the older version?

Close Terminal after the installer finishes. Open a fresh Terminal window through Spotlight so your shell uses the updated installation.

If the older version remains active, help me find which Node.js installation my Mac is using.

ⓧ Command not found

Node.js is not installed yet. The official macOS installer adds Node.js plus npm to your machine.

  • Visit the official Node.js download page.
  • Download the macOS Installer for the current LTS release.
  • Open the downloaded .pkg file.
  • Complete the installer prompts.
  • Return to Terminal.
  • Verify both installed tools by running:
node --version
npm --version

What should you see?

The first value should be 22.21.0 or higher. The second line should show an npm version number.

Commands still unavailable?

Close Terminal after installation. Open a fresh Terminal window through Spotlight so the updated command path is loaded.

If neither command works, help me troubleshoot my Node.js installation on macOS.

Create the workspace and install Express

A Visual Studio Code workspace gives the editor plus its integrated terminal the same project location. This keeps every generated file inside github-oauth-flow-inspector.

  • Open Spotlight by pressing Cmd+Space.
  • Type Visual Studio Code.
  • Press Return to open Visual Studio Code.

Visual Studio Code is ready to create the workspace on your Desktop.

  • Click File in the menu bar.
  • Select Open Folder....
  • Select Desktop in the folder dialog.
  • Select New Folder.
  • Enter github-oauth-flow-inspector as the folder name.
  • Press Return to create the folder.
  • Select Open.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

You should see github-oauth-flow-inspector at the top of the Explorer sidebar. That confirms Visual Studio Code has opened the correct workspace.

The package.json file is a JSON manifest for the workspace. It pins the runtime requirement plus the exact Express version.

  • Select the Explorer view in the Activity Bar.
  • Select New File....
  • Enter package.json.
  • Press Return to create the file.
  • Add the project manifest by pasting this code:
{
  "name": "github-oauth-flow-inspector",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.mjs"
  },
  "engines": {
    "node": ">=22.21.0"
  },
  "dependencies": {
    "express": "5.3.0"
  }
}

What does this manifest control?

  • The name field identifies the local package.
  • The private field prevents accidental publication to the npm registry.
  • The type field enables the Express import syntax used by server.mjs.
  • The start script tells npm how to launch the server.
  • The engines field records the minimum Node.js version.
  • The dependencies field pins Express to 5.3.0.
  • Save package.json by pressing Cmd+S.
  • Confirm that package.json appears in the Explorer sidebar.

✔️ Awesome, I've got everything!

  • Confirm that package.json is saved.

ⓧ I'd like to double check the full code

{
  "name": "github-oauth-flow-inspector",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.mjs"
  },
  "engines": {
    "node": ">=22.21.0"
  },
  "dependencies": {
    "express": "5.3.0"
  }
}

What should match?

Compare every key plus value with your saved file. Matching this reference ensures npm reads the intended dependency configuration.

The manifest names Express as a dependency. npm now has enough information to download that package plus record the resolved dependency tree.

  • Click View in the Visual Studio Code menu bar.
  • Select Terminal.
  • Install the project dependencies by running:
npm install

What just changed?

npm reads the exact dependency from package.json. It installs Express 5.3.0 inside the workspace.

The installation also generates package-lock.json. That lock file records the resolved dependency tree for repeatable installs.

  • Check the Explorer sidebar after the installation finishes.

You should see package-lock.json plus node_modules. The terminal should finish without a dependency error.

Did the installation fail?

Confirm that the integrated terminal is running inside github-oauth-flow-inspector. The folder name appears in the terminal prompt.

If npm mentions invalid JSON, compare package.json with the full-code tab above.

If the problem continues, help me troubleshoot the npm installation in this workspace.

Build and run the starter dashboard

The first server only needs one browser route. That small route proves the local destination works before any GitHub redirects arrive.

The server listens on the loopback address at port 3000. Requests stay on your Mac during this project.

  • Return to the Explorer sidebar.
  • Select New File....
  • Enter server.mjs.
  • Press Return to create the file.
  • Add the starter server by pasting this code:
import express from 'express';

const app = express();
const port = 3000;
const origin = `http://127.0.0.1:${port}`;

app.get('/', (_req, res) => {
  res.send('<h1>GitHub OAuth 2.0 Flow Inspector</h1><p>Local dashboard is running.</p>');
});

app.listen(port, '127.0.0.1', (error) => {
  if (error) throw error;
  console.log(`OAuth Flow Inspector running at ${origin}`);
});

How does the starter server work?

  • The import loads Express from the installed dependency.
  • The app value represents your Express server.
  • The origin value builds the exact local address from port 3000.
  • The / route answers an HTTP request with the starter dashboard markup.
  • The app.listen() call accepts connections through 127.0.0.1.
  • Save server.mjs by pressing Cmd+S.

✔️ Awesome, I've got everything!

  • Confirm that server.mjs is saved.

ⓧ I'd like to double check the full code

import express from 'express';

const app = express();
const port = 3000;
const origin = `http://127.0.0.1:${port}`;

app.get('/', (_req, res) => {
  res.send('<h1>GitHub OAuth 2.0 Flow Inspector</h1><p>Local dashboard is running.</p>');
});

app.listen(port, '127.0.0.1', (error) => {
  if (error) throw error;
  console.log(`OAuth Flow Inspector running at ${origin}`);
});

What should match?

Compare the import plus each server definition with your saved file. The route text should match this reference exactly.

The start script keeps the server process running in the integrated terminal. You can stop it later by pressing Control+C.

Before you run the server, what message do you expect it to print when the loopback address is ready?

  • Start the local server by running:
npm start

What should you see?

The terminal should print OAuth Flow Inspector running at http://127.0.0.1:3000. The process remains active so your browser can reach the route.

You should see the heading GitHub OAuth 2.0 Flow Inspector. Under it, you should see Local dashboard is running.

Dashboard not loading?

Confirm that the integrated terminal still shows the running server message. Keep that process active while you load the page.

If the server stopped during startup, compare server.mjs with the full-code tab. Also confirm the dependency installation completed.

If the page remains unavailable, help me troubleshoot my local Express dashboard.

Your local OAuth destination is live. Next, you will connect that dashboard to GitHub's authorization screen and follow the redirect back to your callback.

Reach the Callback Transaction Gap

Your local Express dashboard gives the authorization journey a visible starting point. The next request leaves your app for GitHub.

A secure OAuth 2.0 callback must connect the response to the browser transaction that started it. This step exposes what happens when that transaction context is missing.

In this step, get ready to:
  • Register a local GitHub OAuth App with exact callback URLs.
  • Generate an authorization request with state and PKCE.
  • Reach the callback without exchanging the returned code.
Register the GitHub OAuth App

A GitHub OAuth App identifies your local server as an OAuth client. Its registered callback URL tells GitHub where to return the browser after authorization.

  • Visit GitHub.com in a new browser tab.
  • Sign in to your GitHub account.
  • Click your profile picture in the top-right corner.
  • Select Settings.
  • Select Developer settings in the left sidebar.
  • Select OAuth apps.
  • Click New OAuth App.

You should now see the registration form for a new OAuth App.

  • Enter GitHub OAuth Flow Inspector in the Application name field.
  • Enter http://127.0.0.1:3000 in the Homepage URL field.
  • Enter http://127.0.0.1:3000/callback in the Authorization callback URL field.
  • Leave wildcard callback matching disabled.
  • Click Register application.

Good progress. Your OAuth App now has an exact route back to the local inspector.

Why use an OAuth App?

The OAuth App exposes the Authorization Code flow directly. You can inspect the redirect parameters before your server performs any token exchange.

Keep the client secret private

The client secret is a live credential. Store it only in .env.

Keep the secret out of screenshots. Never commit the file to source control.

The public template documents which environment values the server expects. It uses placeholders so the real secret stays in your private file.

  • Click New File in the Visual Studio Code Explorer sidebar.
  • Enter .env.example as the file name.

You should see a blank .env.example file in the workspace.

  • Add the environment template by pasting this code:
GITHUB_CLIENT_ID=your-client-id-here
GITHUB_CLIENT_SECRET=your-client-secret-here

What does this template define?

  • The GITHUB_CLIENT_ID value identifies your OAuth App.
  • The GITHUB_CLIENT_SECRET value authenticates your server during a later token exchange.
  • Save .env.example.
  • Confirm that .env.example appears in the Visual Studio Code Explorer.

The ignore file prevents local credentials and installed packages from entering source control.

  • Click New File in the Visual Studio Code Explorer sidebar.
  • Enter .gitignore as the file name.

You should see a blank .gitignore file in the workspace.

  • Define the ignored files by pasting this code:
.env
node_modules/

What does this file protect?

  • The .env entry keeps your local credential file private.
  • The node_modules/ entry excludes installed dependency files.
  • Save .gitignore.
  • Confirm that .gitignore appears beside .env.example in the Explorer.

Your private environment file uses the same variable names. Its values come from the OAuth App you just registered.

  • Click New File in the Visual Studio Code Explorer sidebar.
  • Enter .env as the file name.

You should see a blank .env file in the workspace.

  • Add the private environment structure by pasting this code:
GITHUB_CLIENT_ID=your-client-id-here
GITHUB_CLIENT_SECRET=your-client-secret-here

Why use a separate file?

The template can be shared because it contains placeholder values. The private .env file holds the issued credentials that your local server loads.

  • Return to the OAuth App page in your browser.
  • Copy the issued client ID.
  • Switch back to .env in Visual Studio Code.
  • Replace your-client-id-here with the issued client ID.

Handle the secret carefully

Generating the client secret reveals a credential that represents your OAuth App. Copy it directly into .env without sharing or capturing it.

  • Return to the OAuth App page.
  • Use the client secret control to generate a secret.
  • Copy the issued client secret.
  • Switch back to .env in Visual Studio Code.
  • Replace your-client-secret-here with the issued client secret.
  • Save .env.

Missing a credential value?

Confirm that you are viewing the OAuth App you just registered. The client ID belongs to that app.

Generate a client secret from the same app page. Paste only its value after GITHUB_CLIENT_SECRET=.

Ask for help with finding the OAuth App credentials without exposing them.

Build the authorization redirect

The server now needs its OAuth configuration before it can build an authorization URL. Node.js loads the two private values from .env when the process starts.

  • In server.mjs, replace everything from import express from 'express'; through const origin = `http://127.0.0.1:${port}`; with this code:
import express from 'express';
import { createHash, randomBytes } from 'node:crypto';
import { env, loadEnvFile } from 'node:process';

loadEnvFile();

const app = express();
const port = 3000;
const origin = `http://127.0.0.1:${port}`;
const redirectUri = `${origin}/callback`;
const clientId = env.GITHUB_CLIENT_ID;
const clientSecret = env.GITHUB_CLIENT_SECRET;

if (!clientId || !clientSecret) {
  throw new Error('Add GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET to .env.');
}

What does this configuration do?

  • The environment loader makes the private values available to the Node.js process.
  • The redirectUri value exactly matches the callback URL registered with GitHub.
  • The credential check stops the server when either required value is missing.
  • Save server.mjs.
  • Return to the terminal panel from the previous step.
  • Stop the running server by pressing Control-C.
  • Restart the server with the new environment configuration by running:
npm start

What does this command confirm?

The start script launches server.mjs with the credentials loaded. A successful start confirms that both environment values exist.

  • Refresh http://127.0.0.1:3000 in your browser.

You should still see the starter OAuth Flow Inspector dashboard.

Server stopped during startup?

If the terminal reports Add GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET to .env., confirm that .env is saved in the github-oauth-flow-inspector workspace.

Check that each value starts immediately after its equals sign. Remove any placeholder text left in the file.

Ask for help with debugging why the Node.js server cannot load my private environment values.

The authorization request uses PKCE to connect a random verifier to a derived challenge. It also carries a random state value that the callback should validate.

  • Add the random value helpers and /login route below the credential check:
function randomValue() {
  return randomBytes(32).toString('base64url');
}

function createChallenge(verifier) {
  return createHash('sha256').update(verifier, 'ascii').digest('base64url');
}

app.get('/login', (_req, res) => {
  const state = randomValue();
  const verifier = randomValue();
  const challenge = createChallenge(verifier);

  const authorizationUrl = new URL('https://github.com/login/oauth/authorize');
  authorizationUrl.search = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: 'read:user',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  }).toString();

  return res.redirect(authorizationUrl.toString());
});

How does the redirect work?

  • The randomValue() function creates the transaction-specific verifier and state values.
  • The createChallenge() function hashes the verifier into an S256 challenge.
  • The authorization URL requests the read:user scope for profile access.
  • The temporary route redirects to GitHub without retaining the verifier or binding state to this browser.

The dashboard needs a visible control that starts the new route. The link keeps the first action inside the inspector.

  • Replace the existing / route with this version:
app.get('/', (_req, res) => {
  res.send(`
    <h1>GitHub OAuth 2.0 Flow Inspector</h1>
    <p>Local dashboard is running.</p>
    <a href="/login">Authorize with GitHub</a>
  `);
});

What changed on the dashboard?

The root route still confirms that the local server is running. The new link sends the browser to /login to begin the authorization redirect.

  • Save server.mjs.
  • Return to the terminal panel.
  • Stop the server by pressing Control-C.
  • Restart the inspector by running:
npm start

What does this restart apply?

Node.js loads the updated route definitions when the process starts. The running server now knows how to build the GitHub authorization redirect.

  • Refresh the inspector dashboard in your browser.

You should see an Authorize with GitHub link below the dashboard message.

Authorization link missing?

Confirm that the new anchor sits inside the template returned by the / route. Save server.mjs after making the change.

Restart the server because this project does not reload route changes automatically.

Ask for help with finding why the authorization link is missing from my Express dashboard.

Run into the callback gap

GitHub returns a temporary code and the original state to the registered callback. This version recognizes those values before deliberately stopping the flow.

  • Add the temporary /callback route between /login and app.listen() in server.mjs.
  • Paste this route into that location:
app.get('/callback', (req, res) => {
  const code = typeof req.query.code === 'string' ? req.query.code : '';
  const state = typeof req.query.state === 'string' ? req.query.state : '';

  if (!code || !state) {
    return res.status(400).send(
      '<h1>Callback parameters missing</h1><a href="/">Return to the inspector</a>',
    );
  }

  return res.status(400).send(`
    <h1>Authorization code arrived</h1>
    <p>The callback received the temporary code and state.</p>
    <p>The server did not retain the matching PKCE verifier or bind state to this browser.</p>
    <p>No token request was sent.</p>
    <a href="/">Return to the inspector</a>
  `);
});

Why does the callback stop?

  • The route confirms that GitHub returned both required callback parameters.
  • The server has no retained verifier that belongs to this transaction.
  • The returned state has no browser-bound value to validate against.
  • The route stops before sending a request to the token endpoint.
  • Save server.mjs.
  • Return to the terminal panel.
  • Stop the server by pressing Control-C.
  • Restart the inspector by running:
npm start

What does this restart enable?

The running server now has both sides of the redirect journey. It can start at /login before receiving GitHub's response at /callback.

Before you try the flow, what do you think the callback can safely do with a code when its original verifier is gone?

  • Refresh http://127.0.0.1:3000 in your browser.
  • Click Authorize with GitHub.
  • Approve the request on GitHub.

You should return to the callback page. It says that the authorization code arrived before reporting the missing transaction context.

This shortfall is intentional. No token request is sent.

Flow did not reach the callback?

Compare the OAuth App's homepage URL with http://127.0.0.1:3000. Compare its callback URL with http://127.0.0.1:3000/callback.

Confirm that wildcard callback matching remains disabled. Restart the flow from the dashboard after correcting the registration.

Ask for help with debugging why GitHub does not return to my local callback.

✔️ Awesome, I've got everything!

Your OAuth App is registered. Your private credentials are saved.

The callback now makes the missing transaction context visible without exchanging the code.

ⓧ I'd like to double check the full code

Your private .env file uses the same two names shown below. Its values must be the credentials issued for your OAuth App.

GITHUB_CLIENT_ID=your-client-id-here
GITHUB_CLIENT_SECRET=your-client-secret-here
.env
node_modules/
import express from 'express';
import { createHash, randomBytes } from 'node:crypto';
import { env, loadEnvFile } from 'node:process';

loadEnvFile();

const app = express();
const port = 3000;
const origin = `http://127.0.0.1:${port}`;
const redirectUri = `${origin}/callback`;
const clientId = env.GITHUB_CLIENT_ID;
const clientSecret = env.GITHUB_CLIENT_SECRET;

if (!clientId || !clientSecret) {
  throw new Error('Add GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET to .env.');
}

function randomValue() {
  return randomBytes(32).toString('base64url');
}

function createChallenge(verifier) {
  return createHash('sha256').update(verifier, 'ascii').digest('base64url');
}

app.get('/', (_req, res) => {
  res.send(`
    <h1>GitHub OAuth 2.0 Flow Inspector</h1>
    <p>Local dashboard is running.</p>
    <a href="/login">Authorize with GitHub</a>
  `);
});

app.get('/login', (_req, res) => {
  const state = randomValue();
  const verifier = randomValue();
  const challenge = createChallenge(verifier);

  const authorizationUrl = new URL('https://github.com/login/oauth/authorize');
  authorizationUrl.search = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: 'read:user',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  }).toString();

  return res.redirect(authorizationUrl.toString());
});

app.get('/callback', (req, res) => {
  const code = typeof req.query.code === 'string' ? req.query.code : '';
  const state = typeof req.query.state === 'string' ? req.query.state : '';

  if (!code || !state) {
    return res.status(400).send(
      '<h1>Callback parameters missing</h1><a href="/">Return to the inspector</a>',
    );
  }

  return res.status(400).send(`
    <h1>Authorization code arrived</h1>
    <p>The callback received the temporary code and state.</p>
    <p>The server did not retain the matching PKCE verifier or bind state to this browser.</p>
    <p>No token request was sent.</p>
    <a href="/">Return to the inspector</a>
  `);
});

app.listen(port, '127.0.0.1', (error) => {
  if (error) throw error;
  console.log(`OAuth Flow Inspector running at ${origin}`);
});

You have proved that receiving an authorization code does not complete a secure OAuth transaction. Next, you will retain the PKCE verifier before binding state to the browser.

Bind and Exchange the Authorization Code

Your local Express server can receive an authorization code from GitHub. The previous version proved that the code cannot be trusted or exchanged without its original transaction context.

This step binds the callback to the browser using an HttpOnly cookie. It also retains the PKCE verifier on the server. A matching callback can then complete the Authorization Code flow without exposing the access token.

In this step, get ready to:
  • Store each verifier under its matching state value.
  • Reject callbacks that fail browser-bound state validation.
  • Exchange a valid authorization code with its PKCE verifier.
Bind the verifier to the browser transaction

The server needs two copies of the transaction identity. The browser carries the random state in a cookie while the server uses the same value to find the matching verifier.

  • Return to server.mjs in Visual Studio Code.
  • Replace the import group at the top of the file with this expanded group:
import express from 'express';
import { Buffer } from 'node:buffer';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { env, loadEnvFile } from 'node:process';

Why add these imports?

  • The Buffer import converts state strings into byte sequences for comparison.
  • The timingSafeEqual() import compares equal-length byte sequences without exposing ordinary string-comparison timing.
  • Find the clientSecret declaration near the top of server.mjs.
  • Add the transaction store with both cookie option objects below the missing-credentials check:
const transactions = new Map();
const cookieOptions = {
  httpOnly: true,
  sameSite: 'lax',
  maxAge: 10 * 60 * 1000,
  path: '/',
};
const clearCookieOptions = {
  httpOnly: true,
  sameSite: 'lax',
  path: '/',
};

What does the transaction setup do?

  • The transactions map keeps each verifier under its random state value.
  • The httpOnly option prevents browser JavaScript from reading the state cookie.
  • The sameSite setting allows the top-level return from GitHub while limiting other cross-site cookie use.
  • The ten-minute maxAge limits how long a pending browser transaction remains valid.
  • Find the closing brace of createChallenge().
  • Add the state comparison helpers immediately below it:
function sameValue(left, right) {
  if (!left || !right) return false;

  const leftBuffer = Buffer.from(left);
  const rightBuffer = Buffer.from(right);

  return leftBuffer.length === rightBuffer.length &&
    timingSafeEqual(leftBuffer, rightBuffer);
}

function queryValue(value) {
  return typeof value === 'string' ? value : '';
}

function readCookie(req, name) {
  const header = req.headers.cookie ?? '';
  const pair = header
    .split(';')
    .map((part) => part.trim())
    .find((part) => part.startsWith(`${name}=`));

  if (!pair) return '';

  try {
    return decodeURIComponent(pair.slice(name.length + 1));
  } catch {
    return '';
  }
}

How do these helpers protect the callback?

  • The sameValue() helper rejects missing values before comparing equal-length buffers.
  • The queryValue() helper accepts one string from a query parameter.
  • The readCookie() helper finds the named cookie in the incoming request.

The callback also needs one consistent way to escape values before rendering them. The page helpers keep error details and token metadata inside server-generated HTML.

  • Add escapeHtml() below readCookie() using this code:
function escapeHtml(value) {
  const replacements = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;',
  };

  return String(value).replace(/[&<>"']/g, (character) => replacements[character]);
}

What does escaping protect?

The escapeHtml() helper replaces characters that have special meaning in HTML. Values returned by another system remain visible as text instead of becoming page markup.

  • Add the shared page() function below escapeHtml() using this code:
function page(title, content) {
  return `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="referrer" content="no-referrer">
  <title>${escapeHtml(title)}</title>
  <style>
    :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
    body { margin: 0; background: #0d1117; color: #e6edf3; }
    main { width: min(760px, calc(100% - 32px)); margin: 48px auto; }
    .card { background: #161b22; border: 1px solid #30363d; border-radius: 16px; padding: 28px; }
    .button { display: inline-block; margin-top: 16px; padding: 12px 18px; border-radius: 9px; background: #238636; color: white; text-decoration: none; font-weight: 700; }
    .muted { color: #8b949e; }
    .flow { display: grid; gap: 10px; padding: 0; list-style: none; }
    .flow li { padding: 12px; border-left: 4px solid #58a6ff; background: #0d1117; }
    code { color: #79c0ff; }
    a { color: #58a6ff; }
  </style>
</head>
<body>
  <main>
    <section class="card">
      ${content}
    </section>
  </main>
</body>
</html>`;
}

What does the page helper control?

The page() function gives every inspector screen the same HTML shell. Its referrer policy also stops the callback page from sending its URL as a referrer.

  • Add sendPage() immediately below page() using this code:
function sendPage(res, title, content, status = 200) {
  res.status(status);
  res.set({
    'Cache-Control': 'no-store',
    'Referrer-Policy': 'no-referrer',
  });
  return res.send(page(title, content));
}

Why use one response helper?

The sendPage() helper applies the status code and privacy headers before sending HTML. OAuth response pages should avoid browser caching because their contents can describe sensitive flow state.

  • Replace the existing /login route with this browser-bound version:
app.get('/login', (_req, res) => {
  const state = randomValue();
  const verifier = randomValue();
  const challenge = createChallenge(verifier);

  transactions.set(state, { verifier });
  res.cookie('oauth_state', state, cookieOptions);

  const authorizationUrl = new URL('https://github.com/login/oauth/authorize');
  authorizationUrl.search = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: 'read:user',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  }).toString();

  return res.redirect(authorizationUrl.toString());
});

What changed in the login route?

  • The transactions.set() call retains the verifier under the random state value.
  • The oauth_state cookie binds that state to the browser that started the flow.
  • GitHub receives the derived challenge while the original verifier stays on the local server.
  • Save server.mjs.
  • Return to the terminal running the local server.
  • Stop the current process by pressing Control-C.
  • Restart the inspector by running this command:
npm start

What does this check prove?

A successful restart proves the new imports and helper functions have valid syntax. You should see the local inspector URL in the terminal.

  • Return to the browser tab from the previous step.
  • Refresh http://127.0.0.1:3000.

You should see the OAuth Flow Inspector dashboard again. The server is ready to retain each new transaction.

Server did not restart?

Check that the import group appears only once. Confirm that each helper has a matching closing brace.

Compare the option names against the code above if the terminal points to the cookie configuration.

Ask for help with the exact terminal output: help me fix the syntax error in my OAuth transaction helpers

Validate and consume the callback transaction

A callback is valid only when its returned state matches the browser cookie and identifies a retained transaction. The server consumes that transaction once so the same state and verifier cannot be reused.

  • Find the temporary /callback route in server.mjs.
  • Replace its opening section with this authorization-error handling:
app.get('/callback', async (req, res) => {
  const oauthError = queryValue(req.query.error);
  const oauthErrorDescription = queryValue(req.query.error_description);

  if (oauthError) {
    return sendPage(
      res,
      'Authorization declined',
      `<h1>Authorization did not complete</h1>
       <p>${escapeHtml(oauthError)}: ${escapeHtml(oauthErrorDescription)}</p>
       <a href="/">Return to the inspector</a>`,
      400,
    );
  }

Why handle authorization errors first?

GitHub can return an error when authorization does not complete. This branch renders that outcome before the callback looks for an authorization code.

  • Add the browser-bound state check immediately below the authorization-error branch:
  const code = queryValue(req.query.code);
  const state = queryValue(req.query.state);
  const cookieState = readCookie(req, 'oauth_state');

  if (!code || !sameValue(state, cookieState)) {
    res.clearCookie('oauth_state', clearCookieOptions);
    return sendPage(
      res,
      'State mismatch',
      `<h1>Callback rejected</h1>
       <p>The returned <code>state</code> did not match this browser's OAuth transaction.</p>
       <p>No token request was sent.</p>
       <a href="/">Start a fresh flow</a>`,
      400,
    );
  }

What does the first validation gate prove?

The callback must contain a code. Its state must also match the cookie from the browser that began the flow.

A failed comparison clears the cookie and returns before the token endpoint can receive a request. This is the callback's CSRF protection boundary.

  • Add the retained-transaction check below the state check:
  const transaction = transactions.get(state);

  if (!transaction) {
    res.clearCookie('oauth_state', clearCookieOptions);
    return sendPage(
      res,
      'Transaction missing',
      `<h1>Callback rejected</h1>
       <p>The server no longer has the PKCE verifier for this transaction.</p>
       <p>No token request was sent.</p>
       <a href="/">Start a fresh flow</a>`,
      400,
    );
  }

  transactions.delete(state);
  res.clearCookie('oauth_state', clearCookieOptions);

Why delete the transaction before exchange?

The callback retrieves the verifier only after state validation succeeds. It then deletes the map entry and clears the matching cookie before making any network request.

That order gives the retained state and verifier one use. A repeated callback no longer has the transaction context required for exchange.

Exchange the code with its verifier

The validated callback now has the temporary code and the transaction-specific verifier. The server sends both to GitHub with its confidential client credentials.

  • Add the token request below the cookie-clearing line in the callback:
  try {
    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,
        redirect_uri: redirectUri,
        code_verifier: transaction.verifier,
      }),
    });

    const tokenData = await tokenResponse.json();

What does the exchange send?

  • The client ID identifies the registered OAuth App.
  • The client secret authenticates the local server as the confidential client.
  • The authorization code represents the completed approval.
  • The code_verifier proves this server holds the original secret used to derive the PKCE challenge.
  • Complete the callback with exchange-error handling and the intermediate result page:
    if (!tokenResponse.ok || tokenData.error || !tokenData.access_token) {
      return sendPage(
        res,
        'Token exchange failed',
        `<h1>Token exchange failed</h1>
         <p>${escapeHtml(tokenData.error_description ?? tokenData.error ?? 'GitHub did not return an access token.')}</p>
         <a href="/">Try again</a>`,
        400,
      );
    }

    return sendPage(res, 'OAuth code exchanged', `
      <h1>Authorization code exchanged</h1>
      <p>Granted scope: <code>${escapeHtml(tokenData.scope || '(no scope)')}</code></p>
      <p>Token type: <code>${escapeHtml(tokenData.token_type)}</code></p>
      <p class="muted">The raw access token remained on the server and was not rendered into this page.</p>
      <a class="button" href="/">Run the flow again</a>
    `);
  } catch (requestError) {
    return sendPage(
      res,
      'OAuth request failed',
      `<h1>OAuth request failed</h1>
       <p>${escapeHtml(requestError.message)}</p>
       <a href="/">Try again</a>`,
      500,
    );
  }
});

Why show only token metadata?

The JSON response includes an access token with its token type and granted scope. The result page renders only the type and scope.

The raw access token stays inside the callback's local memory. It never enters the HTML or browser storage.

  • Save server.mjs.
  • Return to the terminal running the inspector.
  • Stop the current process by pressing Control-C.
  • Restart the completed callback by running this command:
npm start

What should the terminal show?

The server should start without a syntax error. You should see the inspector running at http://127.0.0.1:3000.

Before you run the authorization flow, do you expect the callback to stop again or complete the code exchange?

  • Return to http://127.0.0.1:3000 in your browser.
  • Click Authorize with GitHub.
  • Approve the authorization request on GitHub.

You should return to a page headed Authorization code exchanged. The page shows the token type and the granted read:user scope.

That is the callback gap closed. Your app now validates the browser transaction and completes a one-time PKCE exchange without rendering the access token.

Flow stopped before the result page?

Start a fresh flow after every server restart. Restarting clears the in-memory transaction map.

If the callback reports a missing transaction, confirm that transactions.set(state, { verifier }) runs before the redirect.

Ask for help without sharing credentials: help me diagnose why my validated GitHub OAuth callback is not completing its token exchange

Your callback can now validate state and exchange a code with its retained verifier. Next, you will use that delegated access to request your GitHub profile and move the browser to a clean result URL.

Show Your Delegated GitHub Profile

Your callback now rejects mismatched state before it exchanges a code. The validated token still proves little until it authorizes a resource request from GitHub.

Now you will use delegated access to request the authorized profile. The app will move selected fields into a clean result page while the raw access token stays inside the server callback.

In this step, get ready to:
  • Create a local result route for completed OAuth flows.
  • Request the authenticated GitHub profile with delegated access.
  • Display selected profile fields without exposing the authorization code or access token.
Prepare the clean result route

A completed flow needs a separate local record after the callback finishes. A random result ID lets the browser load selected profile data without receiving the token.

  • In server.mjs, find this existing line:
const transactions = new Map();

What does this line show?

The existing transactions map holds pending PKCE transactions. The completed results map belongs beside it.

  • Replace that line with these two map declarations:
const transactions = new Map();
const results = new Map();

What does the new map hold?

The results map holds the safe output from completed flows. Each entry uses a random local ID instead of an access token.

  • In server.mjs, locate app.listen() near the bottom of the file.
  • Insert this initial result route immediately above app.listen():
app.get('/result', (req, res) => {
  const resultId = queryValue(req.query.flow);
  const result = results.get(resultId);

  if (!result) {
    return sendPage(
      res,
      'Result unavailable',
      `<h1>Result unavailable</h1>
       <p>This local result is missing or the server was restarted.</p>
       <a href="/">Start a fresh flow</a>`,
      404,
    );
  }
});

What does this route do?

  • The flow query value identifies one completed local result.
  • The map lookup keeps the selected profile data on the server.
  • The missing-result branch returns a clear page when the ID is invalid or the server has restarted.
  • Save server.mjs.
  • Switch back to the terminal running the server.
  • Stop the current server by pressing Control-C.
  • Restart the inspector by running this command:
npm start

What does this command do?

The npm start command reloads server.mjs through the project start script. The restarted process now serves the new result route.

  • Return to your browser from earlier.
  • Enter http://127.0.0.1:3000/result in the address bar.

You will see the local result-unavailable page. This confirms the route refuses to invent a result when no valid flow ID exists.

Result route not loading?

Check that the route sits above app.listen() in server.mjs. Confirm that the terminal still shows the running inspector process.

Ask for help with the route structure if the browser keeps loading indefinitely: help me check why my Express result route is not responding

Request and store the delegated profile

The token exchange grants delegated access for the requested scope. The server can now use that credential to call the authenticated user endpoint on the learner's behalf.

  • In the /result route in server.mjs, find the closing line directly after the missing-result check.
  • Insert this successful result page immediately before that closing line:
  return sendPage(res, 'OAuth flow completed', `
    <h1>OAuth flow completed</h1>
    <div class="profile">
      <img class="avatar" src="${escapeHtml(result.profile.avatarUrl)}" alt="GitHub avatar">
      <div>
        <h2>${escapeHtml(result.profile.name ?? result.profile.login)}</h2>
        <p>@${escapeHtml(result.profile.login)}</p>
        <p>${escapeHtml(result.profile.publicRepos)} public repositories</p>
        <a href="${escapeHtml(result.profile.profileUrl)}" target="_blank" rel="noreferrer">Open GitHub profile</a>
      </div>
    </div>
    <hr>
    <p>Granted scope: <code>${escapeHtml(result.scope)}</code></p>
    <p>Token type: <code>${escapeHtml(result.tokenType)}</code></p>
    <p class="muted">The raw access token remained on the server and was not rendered into this page.</p>
    <a class="button" href="/">Run the flow again</a>
  `);

How does the result page stay safe?

  • Every profile value passes through escapeHtml() before entering the page.
  • The page displays the granted scope and token type as metadata.
  • The raw access token never enters the rendered HTML.
  • In the /callback route, locate the successful token-response check.
  • Delete the temporary response that renders tokenData.token_type and tokenData.scope.
  • Insert this profile request and result redirect after the successful token-response check:
    const profileResponse = await fetch('https://api.github.com/user', {
      headers: {
        Accept: 'application/vnd.github+json',
        Authorization: `Bearer ${tokenData.access_token}`,
        'User-Agent': 'github-oauth-flow-inspector',
        'X-GitHub-Api-Version': '2026-03-10',
      },
    });

    const profile = await profileResponse.json();

    const resultId = randomValue();
    results.set(resultId, {
      profile: {
        login: profile.login,
        name: profile.name,
        avatarUrl: profile.avatar_url,
        profileUrl: profile.html_url,
        publicRepos: profile.public_repos,
      },
      scope: tokenData.scope || '(no scope)',
      tokenType: tokenData.token_type,
    });

    return res.redirect(`/result?flow=${encodeURIComponent(resultId)}`);

What happens after the exchange?

  • The authenticated user request sends the access token through the authorization header.
  • The results map keeps only the selected profile fields and token metadata.
  • The final redirect replaces the callback URL with a random local result ID.
  • Save server.mjs.
  • Switch back to the terminal running the server.
  • Stop the current server by pressing Control-C.

Before you test the flow, what information do you expect the result page to reveal? What sensitive value should remain absent?

  • Restart the inspector by running this command:
npm start

What does the restart prove?

The restarted process loads both result-page changes and the delegated profile request. A successful authorization can now travel through the complete server flow.

  • Return to the dashboard by entering http://127.0.0.1:3000 in your browser.
  • Click Authorize with GitHub.
  • Confirm that the authorization request asks for the read:user scope.
  • Approve the authorization request on GitHub.

You will return to a local result URL containing only a flow value. The page will show your avatar, profile name, login, public repository count, granted scope, and token type.

Profile page not appearing?

Start a fresh flow if the result page says the local record is missing. Restarting the server clears the in-memory transactions and results maps.

Check that the profile request appears before results.set() in the callback. Confirm that the final redirect uses the same resultId stored in the map.

Ask for help if the callback finishes without showing the profile: help me debug the delegated GitHub profile request

Handle an unsuccessful profile request

A token exchange can succeed while the following resource request fails. The callback needs a dedicated response for that boundary before it stores any profile fields.

  • In server.mjs, find this line inside the callback:
  • const profile = await profileResponse.json();
  • Insert this response check directly below that line:
    if (!profileResponse.ok) {
      return sendPage(
        res,
        'GitHub API request failed',
        `<h1>GitHub API request failed</h1>
         <p>${escapeHtml(profile.message ?? 'The profile request was not successful.')}</p>
         <a href="/">Try again</a>`,
        profileResponse.status,
      );
    }

Why check the profile response?

The check stops the callback before results.set() when the profile response is unsuccessful. The returned message is escaped before the server renders it.

  • Save server.mjs.
  • Switch back to the terminal running the server.
  • Stop the current server by pressing Control-C.

Before the final run, do you expect the completed page URL to contain the authorization code or the access token?

  • Restart the completed inspector by running this command:
npm start

What should the server report?

The terminal will report the local inspector address. The process then waits for a new browser flow.

  • Return to http://127.0.0.1:3000 in your browser.
  • Click Authorize with GitHub.
  • Approve the authorization request on GitHub.
  • Inspect the completed result page.
  • Check the browser address bar for a flow query value.

You will see your GitHub avatar, login, profile name, public repository count, token type, and read:user scope. The address bar will contain neither code nor access_token.

You have closed the OAuth loop. Your server now turns delegated access into a visible profile without leaking the credential that made the request possible.

Final flow not completing?

Start from the dashboard after every server restart because pending transactions live only in memory. Confirm that the callback URL still uses http://127.0.0.1:3000/callback.

Ask for help with the complete flow if the callback returns an error page: help me trace my OAuth callback from token exchange to profile result

✔️ Awesome, I've got everything!

Great work. Save server.mjs so the completed inspector is ready for another flow.

ⓧ I'd like to double check the full code

import express from 'express';
import { Buffer } from 'node:buffer';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { env, loadEnvFile } from 'node:process';

loadEnvFile();

const app = express();
const port = 3000;
const origin = `http://127.0.0.1:${port}`;
const redirectUri = `${origin}/callback`;
const clientId = env.GITHUB_CLIENT_ID;
const clientSecret = env.GITHUB_CLIENT_SECRET;

if (!clientId || !clientSecret) {
  throw new Error('Add GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET to .env.');
}

const transactions = new Map();
const results = new Map();
const cookieOptions = {
  httpOnly: true,
  sameSite: 'lax',
  maxAge: 10 * 60 * 1000,
  path: '/',
};
const clearCookieOptions = {
  httpOnly: true,
  sameSite: 'lax',
  path: '/',
};

function randomValue() {
  return randomBytes(32).toString('base64url');
}

function createChallenge(verifier) {
  return createHash('sha256').update(verifier, 'ascii').digest('base64url');
}

function sameValue(left, right) {
  if (!left || !right) return false;

  const leftBuffer = Buffer.from(left);
  const rightBuffer = Buffer.from(right);

  return leftBuffer.length === rightBuffer.length &&
    timingSafeEqual(leftBuffer, rightBuffer);
}

function queryValue(value) {
  return typeof value === 'string' ? value : '';
}

function readCookie(req, name) {
  const header = req.headers.cookie ?? '';
  const pair = header
    .split(';')
    .map((part) => part.trim())
    .find((part) => part.startsWith(`${name}=`));

  if (!pair) return '';

  try {
    return decodeURIComponent(pair.slice(name.length + 1));
  } catch {
    return '';
  }
}

function escapeHtml(value) {
  const replacements = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;',
  };

  return String(value).replace(/[&<>"']/g, (character) => replacements[character]);
}

function page(title, content) {
  return `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="referrer" content="no-referrer">
  <title>${escapeHtml(title)}</title>
  <style>
    :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
    body { margin: 0; background: #0d1117; color: #e6edf3; }
    main { width: min(760px, calc(100% - 32px)); margin: 48px auto; }
    .card { background: #161b22; border: 1px solid #30363d; border-radius: 16px; padding: 28px; }
    .button { display: inline-block; margin-top: 16px; padding: 12px 18px; border-radius: 9px; background: #238636; color: white; text-decoration: none; font-weight: 700; }
    .muted { color: #8b949e; }
    .flow { display: grid; gap: 10px; padding: 0; list-style: none; }
    .flow li { padding: 12px; border-left: 4px solid #58a6ff; background: #0d1117; }
    .profile { display: flex; gap: 20px; align-items: center; }
    .avatar { width: 96px; height: 96px; border-radius: 50%; }
    code { color: #79c0ff; }
    a { color: #58a6ff; }
  </style>
</head>
<body>
  <main>
    <section class="card">
      ${content}
    </section>
  </main>
</body>
</html>`;
}

function sendPage(res, title, content, status = 200) {
  res.status(status);
  res.set({
    'Cache-Control': 'no-store',
    'Referrer-Policy': 'no-referrer',
  });
  return res.send(page(title, content));
}

app.get('/', (_req, res) => {
  return sendPage(res, 'GitHub OAuth 2.0 Flow Inspector', `
    <h1>GitHub OAuth 2.0 Flow Inspector</h1>
    <p class="muted">Watch a server-side Authorization Code flow use state, PKCE, a token exchange, and delegated API access.</p>
    <ol class="flow">
      <li>1. Create browser-bound state and a PKCE verifier.</li>
      <li>2. Redirect to GitHub for authorization.</li>
      <li>3. Validate the callback and exchange the code.</li>
      <li>4. Call GitHub's authenticated user endpoint.</li>
    </ol>
    <a class="button" href="/login">Authorize with GitHub</a>
  `);
});

app.get('/login', (_req, res) => {
  const state = randomValue();
  const verifier = randomValue();
  const challenge = createChallenge(verifier);

  transactions.set(state, { verifier });
  res.cookie('oauth_state', state, cookieOptions);

  const authorizationUrl = new URL('https://github.com/login/oauth/authorize');
  authorizationUrl.search = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: 'read:user',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  }).toString();

  return res.redirect(authorizationUrl.toString());
});

app.get('/callback', async (req, res) => {
  const oauthError = queryValue(req.query.error);
  const oauthErrorDescription = queryValue(req.query.error_description);

  if (oauthError) {
    return sendPage(
      res,
      'Authorization declined',
      `<h1>Authorization did not complete</h1>
       <p>${escapeHtml(oauthError)}: ${escapeHtml(oauthErrorDescription)}</p>
       <a href="/">Return to the inspector</a>`,
      400,
    );
  }

  const code = queryValue(req.query.code);
  const state = queryValue(req.query.state);
  const cookieState = readCookie(req, 'oauth_state');

  if (!code || !sameValue(state, cookieState)) {
    res.clearCookie('oauth_state', clearCookieOptions);
    return sendPage(
      res,
      'State mismatch',
      `<h1>Callback rejected</h1>
       <p>The returned <code>state</code> did not match this browser's OAuth transaction.</p>
       <p>No token request was sent.</p>
       <a href="/">Start a fresh flow</a>`,
      400,
    );
  }

  const transaction = transactions.get(state);

  if (!transaction) {
    res.clearCookie('oauth_state', clearCookieOptions);
    return sendPage(
      res,
      'Transaction missing',
      `<h1>Callback rejected</h1>
       <p>The server no longer has the PKCE verifier for this transaction.</p>
       <p>No token request was sent.</p>
       <a href="/">Start a fresh flow</a>`,
      400,
    );
  }

  transactions.delete(state);
  res.clearCookie('oauth_state', clearCookieOptions);

  try {
    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,
        redirect_uri: redirectUri,
        code_verifier: transaction.verifier,
      }),
    });

    const tokenData = await tokenResponse.json();

    if (!tokenResponse.ok || tokenData.error || !tokenData.access_token) {
      return sendPage(
        res,
        'Token exchange failed',
        `<h1>Token exchange failed</h1>
         <p>${escapeHtml(tokenData.error_description ?? tokenData.error ?? 'GitHub did not return an access token.')}</p>
         <a href="/">Try again</a>`,
        400,
      );
    }

    const profileResponse = await fetch('https://api.github.com/user', {
      headers: {
        Accept: 'application/vnd.github+json',
        Authorization: `Bearer ${tokenData.access_token}`,
        'User-Agent': 'github-oauth-flow-inspector',
        'X-GitHub-Api-Version': '2026-03-10',
      },
    });

    const profile = await profileResponse.json();

    if (!profileResponse.ok) {
      return sendPage(
        res,
        'GitHub API request failed',
        `<h1>GitHub API request failed</h1>
         <p>${escapeHtml(profile.message ?? 'The profile request was not successful.')}</p>
         <a href="/">Try again</a>`,
        profileResponse.status,
      );
    }

    const resultId = randomValue();
    results.set(resultId, {
      profile: {
        login: profile.login,
        name: profile.name,
        avatarUrl: profile.avatar_url,
        profileUrl: profile.html_url,
        publicRepos: profile.public_repos,
      },
      scope: tokenData.scope || '(no scope)',
      tokenType: tokenData.token_type,
    });

    return res.redirect(`/result?flow=${encodeURIComponent(resultId)}`);
  } catch (requestError) {
    return sendPage(
      res,
      'OAuth request failed',
      `<h1>OAuth request failed</h1>
       <p>${escapeHtml(requestError.message)}</p>
       <a href="/">Try again</a>`,
      500,
    );
  }
});

app.get('/result', (req, res) => {
  const resultId = queryValue(req.query.flow);
  const result = results.get(resultId);

  if (!result) {
    return sendPage(
      res,
      'Result unavailable',
      `<h1>Result unavailable</h1>
       <p>This local result is missing or the server was restarted.</p>
       <a href="/">Start a fresh flow</a>`,
      404,
    );
  }

  return sendPage(res, 'OAuth flow completed', `
    <h1>OAuth flow completed</h1>
    <div class="profile">
      <img class="avatar" src="${escapeHtml(result.profile.avatarUrl)}" alt="GitHub avatar">
      <div>
        <h2>${escapeHtml(result.profile.name ?? result.profile.login)}</h2>
        <p>@${escapeHtml(result.profile.login)}</p>
        <p>${escapeHtml(result.profile.publicRepos)} public repositories</p>
        <a href="${escapeHtml(result.profile.profileUrl)}" target="_blank" rel="noreferrer">Open GitHub profile</a>
      </div>
    </div>
    <hr>
    <p>Granted scope: <code>${escapeHtml(result.scope)}</code></p>
    <p>Token type: <code>${escapeHtml(result.tokenType)}</code></p>
    <p class="muted">The raw access token remained on the server and was not rendered into this page.</p>
    <a class="button" href="/">Run the flow again</a>
  `);
});

app.listen(port, '127.0.0.1', (error) => {
  if (error) throw error;
  console.log(`OAuth Flow Inspector running at ${origin}`);
});

How should you use this reference?

Compare this file with your saved server.mjs. The final version includes the profile request, safe result storage, clean redirect, and escaped result page.

Secret mission

Prove Tampered State Is Rejected

A valid-looking callback can still be forged. You will hold open a legitimate OAuth transaction while a second tab sends the wrong state. A visible rejection will prove the token exchange stays protected before a fresh flow confirms recovery.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your resources, pause the project, or delete everything. The inspector runs locally through GitHub Free with no ongoing project cost.

Resources you used:

  • The running local Express server process.
  • The local github-oauth-flow-inspector Node.js workspace with Express 5.3.0.
  • The private .env file inside the local workspace.
  • The GitHub OAuth App authorization associated with its access token.
  • The persistent GitHub OAuth App registration.

Keep everything running

No deletion is needed. Choose this if you are still using the inspector or want to continue building soon.

  • Leave the local server running while you are actively using the inspector.
  • Press Control-C in the Terminal window when you finish using the inspector.
  • Keep .env private inside the github-oauth-flow-inspector folder.
  • Keep the GitHub OAuth App registration available for your next authorization flow.
  • Restart the local server from the github-oauth-flow-inspector folder by running this command:
npm start

What Does This Command Do?

The start script in package.json launches server.mjs through Node.js.

  • Open http://127.0.0.1:3000 in your browser to confirm the OAuth Flow Inspector dashboard loads again.

Pause - I'll come back to this later

Shut down the local server to free its port. Revoke the existing authorization while keeping your project files available.

  • Press Control-C in the Terminal window that runs the server.
  • Return to your GitHub Settings page.
  • Select Applications.
  • Select Authorized OAuth Apps.
  • Select the authorization for the OAuth App you registered.
  • Select Revoke.

That removes the app's authorization from your account while leaving your local code intact.

  • Keep the github-oauth-flow-inspector folder for your next session.
  • Keep the GitHub OAuth App registration for a fresh authorization later.

Delete - I don't want to use this again

Remove all project resources if you want a clean start. This deletes the remote registration plus the private local workspace.

Deletion Requires a Fresh Setup

Deleting the OAuth App prevents future flows from using that registration. Removing the workspace also deletes your private .env file.

Only continue if you are comfortable repeating the registration and local setup later.

Stop the local server:

  • Press Control-C in the Terminal window that runs the server.

Revoke the existing authorization:

  • Return to your GitHub Settings page.
  • Select Applications.
  • Select Authorized OAuth Apps.
  • Select the authorization for the OAuth App you registered.
  • Select Revoke.

The authorization is now revoked. Its associated token can no longer authorize requests for the app.

Delete the OAuth App registration:

  • Return to your GitHub Settings page.
  • Select Developer settings.
  • Select OAuth apps.
  • Select the OAuth App you registered for this project.
  • Select Advanced.
  • Select Delete application.
  • Select Delete this OAuth Application to confirm removal.

That's the remote cleanup complete. GitHub can no longer start new authorization flows for that registration.

Delete the local workspace:

  • Locate the github-oauth-flow-inspector folder in Finder.
  • Delete the github-oauth-flow-inspector folder in Finder.
  • Confirm the github-oauth-flow-inspector folder no longer appears in Finder.

Your OAuth App registration is gone. Your private local workspace is removed too.

Nice Work!

Nice Work!

Outstanding work! Your local GitHub OAuth Flow Inspector now completes a protected authorization journey while keeping the access token out of browser-visible output.

You've learned how to:

  • Build a local Express OAuth client that makes the Authorization Code flow visible from the first redirect through the callback.
  • Secure the code exchange with transaction-specific PKCE plus browser-bound state validation.
  • Use delegated access to show profile data plus token metadata while the raw access token stays server-side.
  • Secret Mission: Reject a forged callback before token exchange by detecting its mismatched state.

You can now demo the full secure flow from authorization redirect to delegated profile request.

Ready to quiz yourself?