Build a GitHub OAuth Security Dashboard
Build a local GitHub OAuth dashboard with PKCE and protected API access.
Introduction
30 Second Summary
Signing in with GitHub can look like one click. Behind that click, one website asks another to share your account details without handing over your password.
In this project, you will build a local security dashboard that connects to GitHub through OAuth 2.0. You will trace the Authorization Code flow while a Node.js server keeps every credential out of browser code.
What You'll Build
Your signed-in dashboard places your GitHub profile beside a security audit of every OAuth safeguard.
By the end of this project, you'll have:
- A browser-based authorization flow that opens GitHub consent from the Connect GitHub control. You can watch the temporary code produce the planned 401 response.
- A protected token exchange that shows verified state plus PKCE with S256. Your browser never receives the access token or client secret.
- A delegated account dashboard showing your profile, permitted email records, granted scope, and token location.
- Secret Mission: Build a provider-aware disconnect that clears local OAuth state while preserving a link to review or revoke the GitHub authorization.
Are there any prerequisites?
You need experience with JavaScript and web development. You also need a GitHub account with a verified email address plus permission to register an OAuth App.
Before We Start
This step is your chance to commit to a local dashboard at http://127.0.0.1:3000 that displays safe GitHub profile, email, and OAuth audit data. Its Node.js server must keep the access token and client secret away from browser JavaScript, page source, network responses, URLs, and browser storage.
Verify Your Windows Toolchain
OAuth failures can come from the runtime, editor, browser, or account. A known-good Windows toolchain keeps those problems separate from the authorization flow you build later.
You will confirm Node.js and Microsoft Edge first. You will then create a secret-safe project shell in Visual Studio Code.
In this step, get ready to:
- Verify Node.js v24.20.0 on Windows.
- Confirm Microsoft Edge is current and your GitHub account has a verified email address.
- Create the project folder with files that keep local credentials out of Git.
Check Node.js and Microsoft Edge
Node.js runs the local server that handles every OAuth secret. The project uses v24.20.0 so your runtime matches the code you will build.
- Press the Windows key to open Windows Search.
- Type PowerShell and press Enter to open a terminal.
Before you run the check, which version do you expect your current installation to report?
- Check the installed Node.js version by running this command:
node --version
What does this check do?
The --version option asks Node.js to print the active runtime version. The result reveals whether this terminal can find the required installation.
✔️ I see v24.20.0
Your runtime matches the project. Node.js is ready to run the local dashboard.
ⓧ I see an older version
Your terminal found Node.js with a version below v24.20.0. Update it before creating the project.
- Download the official Windows installer for Node.js 24.20.0.
- Run the downloaded installer.
- Accept the default installation choices on each screen.
- Close the current PowerShell window after installation completes.
- Open a fresh PowerShell window through Windows Search.
- Confirm the updated version by running this command:
node --version
Why use a fresh terminal?
A newly opened terminal loads the updated Windows program path. This allows the node command to find the new installation.
You should now see v24.20.0 in the output.
ⓧ Command not found
Windows cannot find a Node.js installation in the current terminal. Install the required runtime before continuing.
- Download the official Windows installer for Node.js 24.20.0.
- Run the downloaded installer.
- Accept the default installation choices on each screen.
- Close the current PowerShell window after installation completes.
- Open a fresh PowerShell window through Windows Search.
- Confirm the installation by running this command:
node --version
Why repeat the check?
The repeated check confirms that Windows can locate Node.js from a newly opened terminal. It also confirms the active installation has the required version.
You should now see v24.20.0 in the output.
Still missing the required version?
Make sure the installer finished before you opened the new terminal. Restart Windows if the old program path remains active.
If another version still appears, confirm that you downloaded the 24.20.0 Windows installer linked above.
Help me fix my Node.js version on Windows.
Microsoft Edge carries the browser side of the OAuth redirect. Your GitHub account also needs a verified email address before you register the OAuth App later.
- Press the Windows key to open Windows Search.
- Type Microsoft Edge and press Enter to open it.
- Enter edge://settings/help in the address bar.
- Confirm the page reports that Microsoft Edge is up to date.
- Open GitHub in a new browser tab.
- Sign in to your GitHub account.
- Open your account email settings to check the email status.
- Confirm at least one account email is verified.
Your runtime, browser, and GitHub account now meet the local project requirements.
Create and open the project folder
A dedicated folder keeps every server file and browser file together. Opening its terminal before running code . also gives Visual Studio Code the correct project context.
- Press the Windows key to open Windows Search.
- Type File Explorer and press Enter to open it.
- Select Desktop in the navigation pane.
- Right-click an empty area of the Desktop folder.
- Select New.
- Select Folder.
- Enter oauth-security-dashboard as the folder name.
- Press Enter to create the folder.
You should now see the empty oauth-security-dashboard folder on your Desktop.
- Double-click oauth-security-dashboard to enter the folder.
- Right-click an empty area inside the folder.
- Select Open in Terminal to start a terminal at this location.
- Open the current folder in Visual Studio Code by running this command:
code .
What does this command do?
The code command launches Visual Studio Code. The dot tells it to use the terminal's current folder as the project.
Visual Studio Code should open with oauth-security-dashboard at the top of the Explorer sidebar.
Does the editor stay closed?
Close the terminal and open a new one so Windows reloads the Visual Studio Code program path. Run the command again from inside oauth-security-dashboard.
Help me open this folder with the Visual Studio Code command.
Create the secret-safe project files
The project separates browser markup, server logic, environment values, and version-control rules. This boundary prepares the files for credentials without placing those credentials in browser code.
Credential files can feel risky. This step uses harmless placeholders while .gitignore keeps the local .env out of Git.
What is each file for?
- The public/index.html file becomes the browser dashboard in the next step.
- The server.mjs file becomes the local Node.js server in the next step.
- The .env.example file documents the required environment variable names.
- The .env file holds local OAuth App values.
- The .gitignore file tells Git to exclude the local credential file.
- Select oauth-security-dashboard in the Visual Studio Code Explorer sidebar.
- Create public with the New Folder control in the Explorer toolbar.
- Select public in the Explorer sidebar.
- Create index.html with the New File control in the Explorer toolbar.
- Select oauth-security-dashboard in the Explorer sidebar.
- Create server.mjs with the New File control.
- Create .env.example with the New File control.
- Create .env with the New File control.
The Explorer now shows the public folder plus four files. The final version-control rule comes next.
- Create .gitignore with the New File control.
- Leave public/index.html empty for the dashboard shell in the next step.
- Leave server.mjs empty for the local server in the next step.
Environment variables let Node.js read configuration from outside the application code. The two project-defined names reserve separate places for the OAuth App identifier and secret.
- Add the credential placeholders to .env.example by pasting this content:
GITHUB_CLIENT_ID=your-client-id-here
GITHUB_CLIENT_SECRET=your-client-secret-here
What do these placeholders represent?
- The GITHUB_CLIENT_ID value identifies the OAuth App to GitHub.
- The GITHUB_CLIENT_SECRET value authenticates the server during token exchange.
- The placeholder values keep this setup safe until you register the OAuth App.
- Save .env.example.
- Confirm the file contains exactly two lines.
Do the environment lines look different?
Check that each variable name uses uppercase letters with underscores. Keep one variable assignment on each line.
Help me compare my environment file with the required format.
- Add the same two lines to .env.
- Save .env.
- Confirm Node.js can load the client identifier by running this command in the Visual Studio Code terminal:
node --env-file=.env -e "console.log(process.env.GITHUB_CLIENT_ID)"
What does this verification prove?
The --env-file=.env option loads values from .env into process.env.
The short JavaScript expression prints only the client identifier placeholder. It does not print the secret placeholder.
You should see your-client-id-here in the terminal. That output proves Node.js can read the local environment file.
Is the client identifier missing?
Confirm the terminal is inside oauth-security-dashboard. Check that the file is named exactly .env.
Check that GITHUB_CLIENT_ID matches the uppercase name shown above.
Help me find why Node.js cannot load my local environment file.
- Add the local environment filename to .gitignore by pasting this content:
.env
Why ignore the local environment file?
The .env file later holds the OAuth App client secret. Listing it in .gitignore prevents Git from treating that local file as project source.
The shareable .env.example keeps placeholders only. It documents the required variable names without exposing credentials.
- Save .gitignore.
- Confirm .env is the only line in the file.
Is the local file still unprotected?
Check that the filename starts with a dot. Confirm the line inside it is exactly .env.
Help me check whether my local environment file is excluded correctly.
✔️ Awesome, I've got everything!
Great. Save every open file before running the final toolchain checks.
ⓧ I'd like to double check the full code
Compare each file with the reference below. The credential values remain placeholders at this stage.
GITHUB_CLIENT_ID=your-client-id-here
GITHUB_CLIENT_SECRET=your-client-secret-here
What belongs in .env.example?
This shareable file contains both required variable names. Its values remain non-sensitive placeholders.
GITHUB_CLIENT_ID=your-client-id-here
GITHUB_CLIENT_SECRET=your-client-secret-here
What belongs in .env?
The local file currently matches the example. You replace these placeholders only after registering the OAuth App.
.env
What belongs in .gitignore?
This single rule excludes the local environment file. The example file remains available as a safe template.
- Confirm public/index.html is empty.
- Confirm server.mjs is empty.
Before you run these final checks, do you expect the runtime, editor, browser, and environment file to report the same setup you just confirmed?
- Verify the active Node.js version by running this command:
node --version
What does this final runtime check prove?
This check confirms that the Visual Studio Code terminal uses the required Node.js installation. It protects later server behavior from version drift.
The terminal should print v24.20.0.
- Verify the current project folder opens in Visual Studio Code by running this command:
code .
What does the editor check prove?
The command confirms that Windows can launch Visual Studio Code from the terminal. The dot keeps the editor focused on oauth-security-dashboard.
Visual Studio Code should remain open with oauth-security-dashboard visible in the Explorer sidebar.
- Switch back to the Microsoft Edge update page from earlier.
- Confirm the page still reports that Microsoft Edge is up to date.
- Return to the Visual Studio Code terminal.
- Verify the local environment file one more time by running this command:
node --env-file=.env -e "console.log(process.env.GITHUB_CLIENT_ID)"
What completes the toolchain check?
This command confirms that Node.js can find .env from the open project folder. The printed placeholder proves the environment boundary is wired correctly.
You should see your-client-id-here in the terminal. Your files remain safe because no live credential has been added.
That's the setup complete: Node.js, Visual Studio Code, Microsoft Edge, GitHub, and your secret boundary are ready. Next, you will turn the empty files into a dashboard shell you can see in Microsoft Edge.
Launch the Dashboard Shell
Your Windows toolchain is ready. The empty project files are waiting in Visual Studio Code.
Before OAuth redirects or credentials enter the picture, you need a visible shell. This small version tests the local editing loop from Node.js to Microsoft Edge.
In this step, get ready to:
- Build the signed-out dashboard shell with hidden data panels.
- Serve the page from a local Node.js HTTP server.
- Verify the Connect GitHub control in Microsoft Edge.
Build the dashboard shell
The browser needs a clear signed-out state before any account data exists. A hidden dashboard reserves space for the protected data you add later.
- Select public/index.html from the file sidebar in Visual Studio Code.
- Replace its blank contents with the code below:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OAuth Security Dashboard</title>
</head>
<body>
<main>
<h1>OAuth Security Dashboard</h1>
<p>Trace a delegated GitHub authorization without exposing credentials to browser code.</p>
<section id="signed-out"><h2>Not connected</h2><p>Authorize read-only access to your GitHub email addresses.</p><a href="/login">Connect GitHub</a></section>
<section id="dashboard" hidden>
<section id="account-panel"></section>
<ul id="email-list"></ul>
<section id="oauth-audit"></section>
</section>
</main>
</body>
</html>
What does this page structure do?
- The signed-out section gives the learner a clear starting state before authorization.
- The href="/login" value reserves /login as the path that starts the GitHub connection flow.
- The hidden attribute keeps the account data out of view until the browser receives an authenticated session.
- The account panel, email list, and OAuth audit section create dedicated targets for later data.
- Save public/index.html.
- Confirm the editor now shows the OAuth Security Dashboard heading above the Connect GitHub link.
Seeing malformed page structure?
Check that every opening HTML tag has the matching closing tag shown above. Confirm that the code is inside public/index.html.
Use this prompt if the structure still looks different: help me compare my public/index.html with the dashboard shell code.
✔️ Awesome, I've got everything!
Your page structure is ready. Keep public/index.html saved.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OAuth Security Dashboard</title>
</head>
<body>
<main>
<h1>OAuth Security Dashboard</h1>
<p>Trace a delegated GitHub authorization without exposing credentials to browser code.</p>
<section id="signed-out"><h2>Not connected</h2><p>Authorize read-only access to your GitHub email addresses.</p><a href="/login">Connect GitHub</a></section>
<section id="dashboard" hidden>
<section id="account-panel"></section>
<ul id="email-list"></ul>
<section id="oauth-audit"></section>
</section>
</main>
</body>
</html>
Serve the dashboard locally
An HTTP server connects the browser address to your HTML file. It also gives every unmatched path a predictable JSON response.
- Select server.mjs from the file sidebar in Visual Studio Code.
- Replace its blank contents with the code below:
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
const HOST = "127.0.0.1";
const PORT = 3000;
const BASE_URL = `http://${HOST}:${PORT}`;
const server = createServer(async (request, response) => {
const url = new URL(request.url, BASE_URL);
if (request.method === "GET" && url.pathname === "/") {
const html = await readFile(new URL("./public/index.html", import.meta.url), {
encoding: "utf8",
});
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(html);
return;
}
response.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
response.end(JSON.stringify({ error: "Not found" }));
});
server.listen(PORT, HOST, () => {
console.log(`OAuth Security Dashboard running at ${BASE_URL}`);
});
What does this server do?
- The readFile import loads public/index.html from disk.
- The createServer import creates the local request handler.
- The HOST and PORT values keep the dashboard at a fixed local address.
- The GET / route returns the HTML page with a successful response.
- The fallback response returns JSON with status 404 for every unmatched path.
- The server.listen() call starts listening for browser requests.
- Save server.mjs.
- Confirm the file ends with the server.listen() call.
Seeing a syntax warning?
Check the braces around the request handler. Confirm that each string uses the same quotation marks shown in the code.
Use this prompt to isolate the mismatch: help me find the syntax issue in server.mjs.
✔️ Awesome, I've got everything!
Your local server code is ready. Keep server.mjs saved.
ⓧ I'd like to double check the full code
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
const HOST = "127.0.0.1";
const PORT = 3000;
const BASE_URL = `http://${HOST}:${PORT}`;
const server = createServer(async (request, response) => {
const url = new URL(request.url, BASE_URL);
if (request.method === "GET" && url.pathname === "/") {
const html = await readFile(new URL("./public/index.html", import.meta.url), {
encoding: "utf8",
});
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(html);
return;
}
response.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
response.end(JSON.stringify({ error: "Not found" }));
});
server.listen(PORT, HOST, () => {
console.log(`OAuth Security Dashboard running at ${BASE_URL}`);
});
Before you start the server, what do you expect Microsoft Edge to show at the local address?
- Return to the terminal from the previous step.
- Start the dashboard server by running this command:
node --env-file=.env server.mjs
What does this command do?
The command loads the local values from .env into process.env. It then runs server.mjs with Node.js.
The server keeps this terminal occupied while it listens for browser requests. Leave the process running during the browser check.
You'll see OAuth Security Dashboard running at http://127.0.0.1:3000 in the terminal. Your local server is now accepting requests.
- Switch back to Microsoft Edge from the previous step.
- Enter http://127.0.0.1:3000 in the address bar.
You'll see the OAuth Security Dashboard heading with a Not connected card. The Connect GitHub control appears below the read-only email explanation.
Dashboard not loading?
- Confirm the terminal still shows the running server process.
- Check that the browser address is exactly http://127.0.0.1:3000.
- Confirm that public/index.html sits inside the public folder.
- Use this prompt for targeted help: help me debug why my local Node.js dashboard is not loading.
That first browser-to-server loop is working. Next, you'll connect the button to a real GitHub authorization flow and inspect the temporary authorization code.
Meet the Authorization Code
Your local dashboard shell already proves that the Node.js server can reach Microsoft Edge. However, the Connect GitHub control has no provider registration or redirect route behind it.
This step registers a GitHub OAuth App for your local dashboard. You will follow the opening stage of the OAuth 2.0 flow before testing the temporary authorization code at the GitHub REST API boundary.
In this step, get ready to:
- Register the local dashboard as a GitHub OAuth App.
- Add the authorization redirect and callback routes.
- Test the temporary authorization code as a bearer credential.
Register the GitHub OAuth App
GitHub needs to recognize your local dashboard before it accepts authorization requests. The registration fixes the exact homepage and callback addresses that belong to your app.
- Switch back to Microsoft Edge from earlier.
- Open your GitHub profile menu from your profile picture.
- Select Settings.
- Select Developer settings in the left sidebar.
- Select OAuth apps.
- Select New OAuth App.
- Select Register a new application if this is your first OAuth App.
The registration form defines where this authorization begins and where GitHub may return the browser. Exact callback matching keeps the redirect tied to your local server.
- Enter OAuth Security Dashboard 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.
- Confirm that wildcard callback matching stays disabled.
- Leave Expire user access tokens enabled.
- Select Register application.
Why use an OAuth App here?
GitHub generally prefers GitHub Apps for production integrations with fine-grained permissions. This local project uses an OAuth App so you can trace each Authorization Code message directly.
That visibility makes the redirect and token boundaries easier to inspect.
The app registration now has a public identifier called the client ID. Your server uses it to tell GitHub which app is requesting access.
- Copy the value shown under Client ID.
- Switch back to the .env tab in Visual Studio Code.
- Replace your-client-id-here with the copied client ID.
The client secret can authorize a server-side exchange on behalf of your app. Treat the next reveal like a password because anyone holding that value could impersonate the application.
- Return to the OAuth App settings page in Microsoft Edge.
- Select Generate a new client secret.
- Complete any identity confirmation that GitHub requests.
- Copy the newly generated secret while GitHub displays it.
- Keep the secret reveal out of screenshots.
- Switch back to the .env tab in Visual Studio Code.
- Replace your-client-secret-here with the generated client secret.
- Save .env.
- Leave .env.example unchanged.
Why keep the secret in .env?
The ignored .env file keeps the real client secret on your machine. The committed .env.example file contains placeholders that document the required settings safely.
This boundary lets the server use the credential without exposing it in browser code or Git history.
That registration is complete. GitHub now recognizes the exact local dashboard and callback involved in your authorization flow.
Build the authorization redirect
The Connect GitHub link already points to /login. That route now needs to construct GitHub's authorization URL with your client ID and the registered callback.
- Stop the running server by pressing Ctrl+C in the terminal.
- Return to server.mjs in Visual Studio Code.
- Find the BASE_URL declaration near the top of the file.
- Add the authorization constants directly below it by pasting:
const CALLBACK_URL = `${BASE_URL}/callback`;
const AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
const API_BASE_URL = "https://api.github.com";
const { GITHUB_CLIENT_ID } = process.env;
What do these values control?
- CALLBACK_URL builds the exact local address registered with GitHub.
- AUTHORIZE_URL identifies the endpoint that starts the browser authorization.
- API_BASE_URL identifies the protected API boundary used for the later test.
- GITHUB_CLIENT_ID reads the app identifier from the local environment.
- Save server.mjs.
- Press the Up Arrow in the terminal to recall the server command from earlier.
- Press Enter to restart the server.
You should see the dashboard startup address again. This confirms that Node.js loaded the new constants from server.mjs.
Server not restarting?
Confirm that the four declarations sit below BASE_URL and above const server.
Check that .env still contains the client ID you copied.
Help me find why the OAuth constants stop server.mjs from starting.
The callback and error paths return small JSON responses. A shared helper gives each response the correct content type while preventing the browser from caching OAuth results.
- Stop the server by pressing Ctrl+C.
- Find the blank line above the const server declaration.
- Add the JSON response helper by pasting:
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
response.end(JSON.stringify(payload));
}
What does this helper do?
- The status code tells the browser whether the request succeeded.
- The JSON content type describes the response format.
- The no-store policy keeps OAuth results out of the browser cache.
- JSON.stringify converts the response object into text that the browser can receive.
- Save server.mjs.
- Press the Up Arrow in the terminal.
- Press Enter to restart the server.
You should see the same startup address. The helper is now available without changing the visible dashboard.
Seeing a syntax error near sendJson?
Confirm that the helper appears outside the server callback. It belongs above the const server declaration.
Compare every parenthesis and closing brace with the snippet.
Help me fix the sendJson helper in server.mjs.
The authorization endpoint responds with a redirect instead of page content. A second helper sends that redirect with the same no-store protection.
- Stop the server by pressing Ctrl+C.
- Find the closing brace of the sendJson() helper.
- Add the redirect helper directly below it by pasting:
function redirect(response, location) {
response.writeHead(302, { Location: location, "Cache-Control": "no-store" });
response.end();
}
How does the redirect work?
The response uses status code 302 with a destination in the Location header. Microsoft Edge follows that destination to GitHub's authorization endpoint.
The response body stays empty because the browser only needs the destination.
- Save server.mjs.
- Press the Up Arrow in the terminal.
- Press Enter to restart the server.
The startup address should appear again. Both response helpers are now ready for the OAuth routes.
Server closes after adding redirect?
Confirm that redirect() sits below sendJson() and above const server.
Check that the response headers remain inside one object.
Help me fix the redirect helper in server.mjs.
The server callback can now route the browser to GitHub. It also needs one error boundary so failed OAuth requests return safe JSON instead of closing the process.
- Stop the server by pressing Ctrl+C.
- Select the existing createServer() construct from const server through its closing });.
- Replace the selected construct with this version:
const server = createServer(async (request, response) => {
const url = new URL(request.url, BASE_URL);
try {
if (request.method === "GET" && url.pathname === "/") {
const html = await readFile(new URL("./public/index.html", import.meta.url), { encoding: "utf8" });
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
response.end(html);
return;
}
if (request.method === "GET" && url.pathname === "/login") {
const authorizeUrl = new URL(AUTHORIZE_URL);
authorizeUrl.searchParams.set("client_id", GITHUB_CLIENT_ID);
authorizeUrl.searchParams.set("redirect_uri", CALLBACK_URL);
authorizeUrl.searchParams.set("scope", "user:email");
redirect(response, authorizeUrl.toString());
return;
}
sendJson(response, 404, { error: "Not found" });
} catch (error) {
console.error(error);
sendJson(response, 500, { error: "The OAuth request failed. Check the server terminal for details." });
}
});
What does the login route do?
- The root route still serves public/index.html.
- The login route builds GitHub's authorization URL from the registered client ID and callback.
- The user:email scope requests read access to the signed-in account's email addresses.
- The error boundary logs server details while returning a generic browser response.
- Save server.mjs.
- Press the Up Arrow in the terminal.
- Press Enter to restart the server.
Before you click the connection control, do you expect the browser to stay on your local dashboard or move to GitHub?
- Return to the local dashboard in Microsoft Edge.
- Select Connect GitHub.
You should reach GitHub's authorization screen for OAuth Security Dashboard. This proves that GET /login built a valid authorization request.
- Use Microsoft Edge's Back control to return to the local dashboard.
Authorization page not opening?
Confirm that the client ID in .env matches the Client ID on the OAuth App settings page.
Restart the server after correcting .env so Node.js reloads the value.
Help me debug why Connect GitHub does not open the authorization page.
Test the temporary code boundary
After approval, GitHub returns a temporary code to /callback. The callback will test that value as a bearer credential while keeping the code itself out of the browser response.
- Stop the server by pressing Ctrl+C.
- Find sendJson(response, 404, { error: "Not found" }); inside createServer().
- Add the callback route directly above that line by pasting:
if (request.method === "GET" && url.pathname === "/callback") {
const code = url.searchParams.get("code");
if (!code) {
sendJson(response, 400, { error: "GitHub authorization was not completed." });
return;
}
const probe = await fetch(`${API_BASE_URL}/user`, { headers: { Authorization: `Bearer ${code}` } });
sendJson(response, 200, { authorizationCodeReceived: true, plannedBearerProbeStatus: probe.status });
return;
}
What does the callback test?
- The callback reads the temporary code from the returned URL.
- The missing-code path stops incomplete authorization attempts.
- The probe sends the code in an Authorization: Bearer header to the authenticated user endpoint.
- The browser receives only confirmation and the response status.
The temporary code never appears in the rendered JSON.
- Save server.mjs.
- Press the Up Arrow in the terminal.
- Press Enter to restart the server.
Before you approve the request, do you think GitHub will accept the temporary code as an API credential?
- Return to the local dashboard in Microsoft Edge.
- Select Connect GitHub.
- Approve the request for access to your email addresses.
You should return to the callback and see authorizationCodeReceived: true with plannedBearerProbeStatus: 401.
You found the protocol boundary. The temporary code reached your server safely, but GitHub rejected it as an API access token.
What did the 401 prove?
A GitHub authorization code is a short-lived grant that belongs at the token endpoint. The authenticated user endpoint expects an access token in its Authorization: Bearer header.
The planned 401 proves that these credentials have different jobs.
Callback not reaching the planned result?
Confirm that the OAuth App callback is exactly http://127.0.0.1:3000/callback.
If the browser shows a generic failure, inspect the server terminal for the underlying request error.
Help me debug why my OAuth callback does not show the planned 401 status.
✔️ Awesome, I've got everything!
Your OAuth App registration and local files are ready. Double-check that server.mjs and .env are saved.
ⓧ I'd like to double check the full code
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
const HOST = "127.0.0.1";
const PORT = 3000;
const BASE_URL = `http://${HOST}:${PORT}`;
const CALLBACK_URL = `${BASE_URL}/callback`;
const AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
const API_BASE_URL = "https://api.github.com";
const { GITHUB_CLIENT_ID } = process.env;
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
response.end(JSON.stringify(payload));
}
function redirect(response, location) {
response.writeHead(302, { Location: location, "Cache-Control": "no-store" });
response.end();
}
const server = createServer(async (request, response) => {
const url = new URL(request.url, BASE_URL);
try {
if (request.method === "GET" && url.pathname === "/") {
const html = await readFile(new URL("./public/index.html", import.meta.url), { encoding: "utf8" });
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
response.end(html);
return;
}
if (request.method === "GET" && url.pathname === "/login") {
const authorizeUrl = new URL(AUTHORIZE_URL);
authorizeUrl.searchParams.set("client_id", GITHUB_CLIENT_ID);
authorizeUrl.searchParams.set("redirect_uri", CALLBACK_URL);
authorizeUrl.searchParams.set("scope", "user:email");
redirect(response, authorizeUrl.toString());
return;
}
if (request.method === "GET" && url.pathname === "/callback") {
const code = url.searchParams.get("code");
if (!code) {
sendJson(response, 400, { error: "GitHub authorization was not completed." });
return;
}
const probe = await fetch(`${API_BASE_URL}/user`, { headers: { Authorization: `Bearer ${code}` } });
sendJson(response, 200, { authorizationCodeReceived: true, plannedBearerProbeStatus: probe.status });
return;
}
sendJson(response, 404, { error: "Not found" });
} catch (error) {
console.error(error);
sendJson(response, 500, { error: "The OAuth request failed. Check the server terminal for details." });
}
});
server.listen(PORT, HOST, () => {
console.log(`OAuth Security Dashboard running at ${BASE_URL}`);
});
What should match?
- The constants should include the callback, authorization endpoint, API base URL, and environment client ID.
- The server should provide the root, login, and callback routes.
- The callback response should expose the probe status without exposing the temporary code.
Your dashboard can now receive a temporary authorization code and prove why it cannot call the API directly. Next, you will add state validation and PKCE before exchanging that code on the server.
Secure the Token Exchange
Your planned 401 proved that the temporary authorization code from GitHub cannot act as an API credential. That failed request exposed the missing server-side exchange.
The server now needs to protect the OAuth 2.0 redirect before trusting the resulting token. A random state value validates the callback request. PKCE binds the authorization request to the token exchange.
In this step, get ready to:
- Generate the state value and PKCE challenge in Node.js process memory.
- Validate the callback state before accepting the authorization code.
- Exchange the code for a token that remains on the server.
Build the in-memory security state
The server needs temporary security values while one authorization is in progress. It also needs a separate session object for the token returned after a successful exchange.
- In server.mjs, replace the two existing import lines with these imports:
import { Buffer } from "node:buffer";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
What do these imports provide?
- Buffer converts returned state values into byte sequences for comparison.
- randomBytes creates values that an attacker cannot predict.
- createHash derives the SHA-256 PKCE challenge from the original verifier.
- timingSafeEqual compares equal-length values without exposing useful timing differences.
- Save server.mjs.
- Stop the running server by pressing Ctrl+C in the Visual Studio Code terminal.
- Restart the server with the new imports by running:
node --env-file=.env server.mjs
What does this command do?
Node.js loads the ignored credentials from .env before starting server.mjs. The startup message confirms that the new imports load successfully.
You should see OAuth Security Dashboard running at http://127.0.0.1:3000 in the terminal.
Server failing after the import change?
Compare all four import paths with the code block. Check that every quoted path and imported name matches exactly.
Ask for help with the import failure by sharing the terminal error without sharing your credentials: help me diagnose the Node.js import error in server.mjs
- In server.mjs, replace the constants from const HOST through the existing environment credential line with this configuration:
const HOST = "127.0.0.1";
const PORT = 3000;
const BASE_URL = `http://${HOST}:${PORT}`;
const CALLBACK_URL = `${BASE_URL}/callback`;
const AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
const TOKEN_URL = "https://github.com/login/oauth/access_token";
const API_BASE_URL = "https://api.github.com";
const API_VERSION = "2026-03-10";
const USER_AGENT = "OAuth-Security-Dashboard";
const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } = process.env;
if (
!GITHUB_CLIENT_ID ||
!GITHUB_CLIENT_SECRET ||
GITHUB_CLIENT_ID.startsWith("your-") ||
GITHUB_CLIENT_SECRET.startsWith("your-")
) {
throw new Error("Add your GitHub OAuth App credentials to .env before starting the server.");
}
What does this configuration protect?
- TOKEN_URL identifies the provider endpoint that accepts the temporary authorization code.
- GITHUB_CLIENT_SECRET is read only by the Node.js process.
- The credential check stops the server before a broken authorization request can begin.
- USER_AGENT and API_VERSION prepare the server for authenticated GitHub API requests.
- Insert the in-memory state and security helpers above sendJson() in server.mjs:
let pendingAuth = null;
let session = emptySession();
function emptySession() {
return {
accessToken: null,
tokenType: null,
grantedScope: "",
expiresIn: null,
stateVerified: false,
pkceMethod: null,
};
}
function randomBase64url(byteLength = 32) {
return randomBytes(byteLength).toString("base64url");
}
function safeEqual(leftValue, rightValue) {
const left = Buffer.from(leftValue);
const right = Buffer.from(rightValue);
return left.length === right.length && timingSafeEqual(left, right);
}
How does the security state work?
- pendingAuth holds one state value and one PKCE verifier during an active authorization.
- session holds the access token and safe audit metadata after the exchange succeeds.
- randomBase64url() creates URL-safe random values for the state and verifier.
- safeEqual() checks the byte lengths before calling timingSafeEqual().
- Save server.mjs.
- Stop the current server by pressing Ctrl+C in the terminal.
- Restart the server with the security state by running:
node --env-file=.env server.mjs
What does this restart prove?
The restart executes the credential guard and initializes an empty in-memory session. A normal startup message confirms that both foundations are valid.
You should see the local dashboard startup message again. Your credentials remain inside the Node.js process.
Protect the authorization round trip
The login route must create a new security pair for every authorization attempt. GitHub receives the state value and the derived challenge while the original verifier remains in process memory.
- In server.mjs, replace the existing GET /login route with this protected route:
if (request.method === "GET" && url.pathname === "/login") {
const state = randomBase64url();
const verifier = randomBase64url();
const challenge = createHash("sha256")
.update(verifier)
.digest("base64url");
pendingAuth = { state, verifier };
const authorizeUrl = new URL(AUTHORIZE_URL);
authorizeUrl.searchParams.set("client_id", GITHUB_CLIENT_ID);
authorizeUrl.searchParams.set("redirect_uri", CALLBACK_URL);
authorizeUrl.searchParams.set("scope", "user:email");
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("code_challenge", challenge);
authorizeUrl.searchParams.set("code_challenge_method", "S256");
redirect(response, authorizeUrl.toString());
return;
}
What does the protected login route do?
- state gives the callback a random value that must return unchanged.
- verifier stays in process memory for the later token exchange.
- challenge is the SHA-256 base64url form of the verifier.
- S256 tells GitHub which transformation produced the challenge.
- Save server.mjs.
- Stop the current server by pressing Ctrl+C in the terminal.
- Restart the protected login route by running:
node --env-file=.env server.mjs
What does this restart activate?
The new server process uses the PKCE-protected login route. Each request to /login now creates a fresh state value and verifier.
Before you connect, do you expect the old bearer probe to succeed or still return the planned 401?
- Return to http://127.0.0.1:3000 in Microsoft Edge.
- Click Connect GitHub.
- Complete the GitHub authorization request.
You should still receive the planned 401 result. The authorization request is now protected while the callback still runs the temporary probe.
- Inside the existing callback route, replace the old code retrieval and missing-code check with this state validation:
if (url.searchParams.has("error")) {
pendingAuth = null;
sendJson(response, 400, { error: "GitHub authorization was not completed." });
return;
}
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
const expected = pendingAuth;
pendingAuth = null;
if (
!expected ||
!code ||
!returnedState ||
!safeEqual(returnedState, expected.state)
) {
sendJson(response, 400, { error: "OAuth state validation failed." });
return;
}
Why consume the pending authorization?
The route copies pendingAuth before clearing it. A callback can therefore use each pending authorization only once.
Missing values and mismatched state values stop the flow before the server contacts the token endpoint. The token exchange only starts after the redirect is verified.
Exchange the code on the server
The validated code can now be exchanged through the provider's token endpoint. The browser never performs this request because the exchange requires the client secret and original verifier.
- Replace the two planned bearer-probe lines with the next two code chunks in order:
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"User-Agent": USER_AGENT,
},
body: new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: CALLBACK_URL,
code_verifier: expected.verifier,
}),
});
const tokenPayload = await tokenResponse.json();
if (!tokenResponse.ok || !tokenPayload.access_token) {
throw new Error(`Token exchange failed: ${tokenPayload.error ?? tokenResponse.status}`);
}
What does the exchange request send?
- client_id and client_secret identify the registered OAuth App to GitHub.
- code carries the temporary grant returned to the callback.
- redirect_uri matches the callback used during authorization.
- code_verifier proves that this server started the PKCE-protected request.
session = {
accessToken: tokenPayload.access_token,
tokenType: tokenPayload.token_type ?? "bearer",
grantedScope: tokenPayload.scope ?? "",
expiresIn: Number.isFinite(Number(tokenPayload.expires_in))
? Number(tokenPayload.expires_in)
: null,
stateVerified: true,
pkceMethod: "S256",
};
redirect(response, "/");
return;
What stays in server memory?
The session keeps the returned token with its type and granted scope. It also records that state validation passed and that PKCE used S256.
The callback redirects the browser to / without placing the token in the redirect URL or response body.
- Insert this API helper immediately above the createServer() call:
async function githubApi(path, accessToken) {
return fetch(`${API_BASE_URL}${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": API_VERSION,
"User-Agent": USER_AGENT,
},
});
}
Why keep API requests on the server?
The helper adds the access token only to requests sent by Node.js. Browser code receives projected account data instead of the bearer credential.
This server boundary prepares the GitHub REST API data used by the dashboard in the next step.
- Insert the next four adjacent chunks before the existing 404 response. Keep them in the order shown:
if (request.method === "GET" && url.pathname === "/api/session") {
if (!session.accessToken) {
sendJson(response, 200, { authenticated: false });
return;
}
const profileResponse = await githubApi("/user", session.accessToken);
if (profileResponse.status === 401) {
session = emptySession();
sendJson(response, 401, {
authenticated: false,
error: "The GitHub token is no longer valid. Connect again.",
});
return;
}
if (!profileResponse.ok) {
throw new Error(`GitHub profile request failed with ${profileResponse.status}.`);
}
How does the session route handle the token?
The route returns a signed-out result when no token exists. A 401 from GitHub clears the invalid token from memory.
Other provider failures stay on the server as errors. The browser never receives the bearer value.
const currentScopes =
profileResponse.headers.get("x-oauth-scopes") ?? session.grantedScope;
const scopeSet = new Set(
currentScopes
.split(",")
.map((scope) => scope.trim())
.filter(Boolean),
);
const hasEmailAccess =
scopeSet.has("user:email") || scopeSet.has("user");
const profile = await profileResponse.json();
let emails = [];
if (hasEmailAccess) {
const emailResponse = await githubApi("/user/emails", session.accessToken);
if (emailResponse.status === 401) {
session = emptySession();
sendJson(response, 401, {
authenticated: false,
error: "The GitHub token is no longer valid. Connect again.",
});
return;
}
if (!emailResponse.ok) {
throw new Error(`GitHub email request failed with ${emailResponse.status}.`);
}
emails = await emailResponse.json();
}
Why inspect the granted scopes?
The provider response reports the scopes currently authorized for the token. The route requests email records only when user:email or its parent user scope is present.
This check ties each protected API request to permission that the account actually granted.
sendJson(response, 200, {
authenticated: true,
profile: {
login: profile.login,
name: profile.name,
avatarUrl: profile.avatar_url,
htmlUrl: profile.html_url,
},
emails: emails.map((item) => ({
email: item.email,
primary: item.primary,
verified: item.verified,
visibility: item.visibility,
})),
audit: {
What reaches the browser?
The response projects only the profile and email fields needed by the dashboard. It omits the original provider payload and access token.
The following audit fields explain the completed flow without revealing credentials.
authorizationCodeExchanged: true,
stateVerified: session.stateVerified,
pkceMethod: session.pkceMethod,
grantedScopes: currentScopes || "(none)",
tokenType: session.tokenType,
tokenLocation: "Node.js process memory only",
expiresIn: session.expiresIn,
emailAccessGranted: hasEmailAccess,
},
});
return;
}
What does the audit metadata prove?
The audit records the successful code exchange and state result. It also reports the PKCE method and token location.
These values make the security design visible while the actual token remains in process memory.
✔️ Awesome, I've got everything!
Great. Save server.mjs before restarting the local server.
ⓧ I'd like to double check the full code
import { Buffer } from "node:buffer";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
const HOST = "127.0.0.1";
const PORT = 3000;
const BASE_URL = `http://${HOST}:${PORT}`;
const CALLBACK_URL = `${BASE_URL}/callback`;
const AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
const TOKEN_URL = "https://github.com/login/oauth/access_token";
const API_BASE_URL = "https://api.github.com";
const API_VERSION = "2026-03-10";
const USER_AGENT = "OAuth-Security-Dashboard";
const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } = process.env;
if (
!GITHUB_CLIENT_ID ||
!GITHUB_CLIENT_SECRET ||
GITHUB_CLIENT_ID.startsWith("your-") ||
GITHUB_CLIENT_SECRET.startsWith("your-")
) {
throw new Error("Add your GitHub OAuth App credentials to .env before starting the server.");
}
let pendingAuth = null;
let session = emptySession();
function emptySession() {
return {
accessToken: null,
tokenType: null,
grantedScope: "",
expiresIn: null,
stateVerified: false,
pkceMethod: null,
};
}
function randomBase64url(byteLength = 32) {
return randomBytes(byteLength).toString("base64url");
}
function safeEqual(leftValue, rightValue) {
const left = Buffer.from(leftValue);
const right = Buffer.from(rightValue);
return left.length === right.length && timingSafeEqual(left, right);
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
});
response.end(JSON.stringify(payload));
}
function redirect(response, location) {
response.writeHead(302, { Location: location, "Cache-Control": "no-store" });
response.end();
}
async function githubApi(path, accessToken) {
return fetch(`${API_BASE_URL}${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": API_VERSION,
"User-Agent": USER_AGENT,
},
});
}
const server = createServer(async (request, response) => {
const url = new URL(request.url, BASE_URL);
try {
if (request.method === "GET" && url.pathname === "/") {
const html = await readFile(new URL("./public/index.html", import.meta.url), {
encoding: "utf8",
});
response.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
});
response.end(html);
return;
}
if (request.method === "GET" && url.pathname === "/login") {
const state = randomBase64url();
const verifier = randomBase64url();
const challenge = createHash("sha256")
.update(verifier)
.digest("base64url");
pendingAuth = { state, verifier };
const authorizeUrl = new URL(AUTHORIZE_URL);
authorizeUrl.searchParams.set("client_id", GITHUB_CLIENT_ID);
authorizeUrl.searchParams.set("redirect_uri", CALLBACK_URL);
authorizeUrl.searchParams.set("scope", "user:email");
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("code_challenge", challenge);
authorizeUrl.searchParams.set("code_challenge_method", "S256");
redirect(response, authorizeUrl.toString());
return;
}
if (request.method === "GET" && url.pathname === "/callback") {
if (url.searchParams.has("error")) {
pendingAuth = null;
sendJson(response, 400, { error: "GitHub authorization was not completed." });
return;
}
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
const expected = pendingAuth;
pendingAuth = null;
if (
!expected ||
!code ||
!returnedState ||
!safeEqual(returnedState, expected.state)
) {
sendJson(response, 400, { error: "OAuth state validation failed." });
return;
}
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"User-Agent": USER_AGENT,
},
body: new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: CALLBACK_URL,
code_verifier: expected.verifier,
}),
});
const tokenPayload = await tokenResponse.json();
if (!tokenResponse.ok || !tokenPayload.access_token) {
throw new Error(`Token exchange failed: ${tokenPayload.error ?? tokenResponse.status}`);
}
session = {
accessToken: tokenPayload.access_token,
tokenType: tokenPayload.token_type ?? "bearer",
grantedScope: tokenPayload.scope ?? "",
expiresIn: Number.isFinite(Number(tokenPayload.expires_in))
? Number(tokenPayload.expires_in)
: null,
stateVerified: true,
pkceMethod: "S256",
};
redirect(response, "/");
return;
}
if (request.method === "GET" && url.pathname === "/api/session") {
if (!session.accessToken) {
sendJson(response, 200, { authenticated: false });
return;
}
const profileResponse = await githubApi("/user", session.accessToken);
if (profileResponse.status === 401) {
session = emptySession();
sendJson(response, 401, {
authenticated: false,
error: "The GitHub token is no longer valid. Connect again.",
});
return;
}
if (!profileResponse.ok) {
throw new Error(`GitHub profile request failed with ${profileResponse.status}.`);
}
const currentScopes =
profileResponse.headers.get("x-oauth-scopes") ?? session.grantedScope;
const scopeSet = new Set(
currentScopes
.split(",")
.map((scope) => scope.trim())
.filter(Boolean),
);
const hasEmailAccess =
scopeSet.has("user:email") || scopeSet.has("user");
const profile = await profileResponse.json();
let emails = [];
if (hasEmailAccess) {
const emailResponse = await githubApi("/user/emails", session.accessToken);
if (emailResponse.status === 401) {
session = emptySession();
sendJson(response, 401, {
authenticated: false,
error: "The GitHub token is no longer valid. Connect again.",
});
return;
}
if (!emailResponse.ok) {
throw new Error(`GitHub email request failed with ${emailResponse.status}.`);
}
emails = await emailResponse.json();
}
sendJson(response, 200, {
authenticated: true,
profile: {
login: profile.login,
name: profile.name,
avatarUrl: profile.avatar_url,
htmlUrl: profile.html_url,
},
emails: emails.map((item) => ({
email: item.email,
primary: item.primary,
verified: item.verified,
visibility: item.visibility,
})),
audit: {
authorizationCodeExchanged: true,
stateVerified: session.stateVerified,
pkceMethod: session.pkceMethod,
grantedScopes: currentScopes || "(none)",
tokenType: session.tokenType,
tokenLocation: "Node.js process memory only",
expiresIn: session.expiresIn,
emailAccessGranted: hasEmailAccess,
},
});
return;
}
sendJson(response, 404, { error: "Not found" });
} catch (error) {
console.error(error);
sendJson(response, 500, {
error: "The OAuth request failed. Check the server terminal for details.",
});
}
});
server.listen(PORT, HOST, () => {
console.log(`OAuth Security Dashboard running at ${BASE_URL}`);
});
- Save server.mjs.
- Stop the current server by pressing Ctrl+C in the terminal.
- Start the completed token exchange by running:
node --env-file=.env server.mjs
What does the completed server now do?
The server creates a protected authorization request and validates the callback. It exchanges the code before storing the resulting token in process memory.
The startup message confirms that the complete security path is ready for a browser test.
Before you connect, do you expect the access token to appear anywhere in the browser after the callback completes?
- Return to http://127.0.0.1:3000 in Microsoft Edge.
- Click Connect GitHub.
- Complete the GitHub authorization request.
- Confirm that the address bar returns to http://127.0.0.1:3000.
- Visit http://127.0.0.1:3000/api/session to inspect the safe server response.
- Check the page source for any rendered access token value.
You should see authenticated set to true in the session response. The audit data should show verified state and S256.
The address bar and response body contain no access token. That is the hard part complete: the browser can verify the flow without receiving the credential.
Token exchange not completing?
Confirm that the callback URL in GitHub still matches http://127.0.0.1:3000/callback exactly. Restart the authorization from Connect GitHub because each pending state value works once.
Check the server terminal for the failed stage without copying your client secret. help me diagnose my OAuth state or PKCE token exchange failure
Your authorization code now becomes a server-held token only after state and PKCE checks pass. Next up, you will turn that delegated access into the visible account and email dashboard.
Build the Delegated Access Dashboard
Your protected callback now exchanges the temporary authorization code for a token. That token stays inside Node.js process memory.
A stored token becomes useful when the server retrieves protected GitHub data. This step returns a safe projection to the browser so credentials never cross the server boundary.
In this step, get ready to:
- Verify the server-side boundary for delegated requests.
- Build dashboard fields for profile details, email records, and audit metadata.
- Render the delegated session without exposing credentials.
Inspect the server-side data boundary
The GitHub REST API receives authenticated requests from your server. The browser receives selected account fields through /api/session.
- Switch back to server.mjs in Visual Studio Code.
- Find async function githubApi(path, accessToken).
- Locate the route containing url.pathname === "/api/session".
- Trace the response object passed to sendJson().
How does the server protect the token?
- The Authorization: Bearer header authenticates each request from the server.
- The Accept: application/vnd.github+json header requests GitHub's JSON representation.
- The X-GitHub-Api-Version: 2026-03-10 header pins the API behavior.
- The User-Agent: OAuth-Security-Dashboard header identifies your application.
- The route reads X-OAuth-Scopes to determine the token's current permissions.
- The server requests /user/emails only when user:email or user is present.
- A 401 response clears the in-memory session so an invalid token cannot be reused.
Before you inspect the session endpoint, do you expect its response to contain the access token?
- Return to the Microsoft Edge session from earlier.
- Load the session response by entering http://127.0.0.1:3000/api/session in the address bar.
- Check the response for the profile object.
- Check the response for the emails collection.
- Check the response for the audit object.
You should see an authenticated JSON response containing account data plus security metadata. The access token and client secret are absent.
Seeing an unauthenticated response?
Your token lives only in the running Node.js process. A server restart clears that session.
Return to http://127.0.0.1:3000. Use the Connect GitHub control to complete the protected flow again.
Help me diagnose why /api/session is unauthenticated.
Build the dashboard structure
The browser needs fixed destinations for each safe response field. These DOM elements separate account data from security audit data.
- Return to public/index.html in Visual Studio Code.
- Find the existing <main> element.
- Replace the entire element by pasting the code below:
<main>
<h1>OAuth Security Dashboard</h1>
<p>Trace a delegated GitHub authorization without exposing credentials to browser code.</p>
<section id="signed-out" hidden><h2>Not connected</h2><p>Authorize read-only access to your GitHub email addresses.</p><a href="/login">Connect GitHub</a></section>
<p id="error" hidden></p>
<section id="dashboard" hidden>
<article><img id="avatar" alt="GitHub avatar" /><h2 id="name"></h2><a id="profile-link" target="_blank" rel="noreferrer"></a></article>
<article><h2>Authorized email data</h2><ul id="email-list"></ul><p id="email-warning" hidden>The required email scope was not granted.</p></article>
<article><h2>OAuth audit</h2><dl><dt>Authorization code exchanged</dt><dd id="code-exchanged"></dd><dt>State validation</dt><dd id="state-result"></dd><dt>PKCE method</dt><dd id="pkce-method"></dd><dt>Granted scopes</dt><dd id="granted-scopes"></dd><dt>Token type</dt><dd id="token-type"></dd><dt>Token location</dt><dd id="token-location"></dd></dl></article>
</section>
</main>
How does this structure support safe rendering?
- The signed-out section contains the existing authorization control.
- The dashboard section starts hidden until the server confirms authentication.
- The profile elements provide fixed destinations for the avatar, display name, and profile link.
- The email list accepts one browser-created list item per permitted address.
- The audit fields display security results without displaying credential values.
- Save public/index.html.
- Return to the Microsoft Edge address bar.
- Load http://127.0.0.1:3000.
You should see the dashboard heading plus its description. The account sections are hidden until the session loader chooses the correct state.
Still seeing the old shell?
Confirm that you edited the public/index.html file inside oauth-security-dashboard.
Save the file before loading the local URL again.
Help me find why my updated public/index.html is not appearing.
Render the delegated session
The session loader turns the server's safe response into visible account data. It assigns API values as text so returned content remains data inside the page.
- Place your cursor immediately before the closing </body> tag in public/index.html.
- Add the session loader by pasting the code below:
<script>
const signedOut = document.querySelector("#signed-out");
const dashboard = document.querySelector("#dashboard");
const errorBox = document.querySelector("#error");
async function loadSession() {
const response = await fetch("/api/session", { headers: { Accept: "application/json" }, cache: "no-store" });
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? "Unable to load the OAuth session.");
if (!data.authenticated) { signedOut.hidden = false; dashboard.hidden = true; return; }
signedOut.hidden = true; dashboard.hidden = false;
document.querySelector("#avatar").src = data.profile.avatarUrl;
document.querySelector("#name").textContent = data.profile.name || data.profile.login;
const profileLink = document.querySelector("#profile-link");
profileLink.href = data.profile.htmlUrl; profileLink.textContent = `@${data.profile.login}`;
const emailList = document.querySelector("#email-list"); emailList.replaceChildren();
for (const item of data.emails) { const row = document.createElement("li"); const labels = [item.primary ? "primary" : null, item.verified ? "verified" : "unverified", item.visibility ?? "private"].filter(Boolean); row.textContent = `${item.email} (${labels.join(", ")})`; emailList.append(row); }
document.querySelector("#email-warning").hidden = data.audit.emailAccessGranted;
document.querySelector("#code-exchanged").textContent = String(data.audit.authorizationCodeExchanged);
document.querySelector("#state-result").textContent = data.audit.stateVerified ? "verified" : "failed";
document.querySelector("#pkce-method").textContent = data.audit.pkceMethod;
document.querySelector("#granted-scopes").textContent = data.audit.grantedScopes;
document.querySelector("#token-type").textContent = data.audit.tokenType;
document.querySelector("#token-location").textContent = data.audit.tokenLocation;
}
loadSession().catch((error) => { errorBox.textContent = error.message; errorBox.hidden = false; signedOut.hidden = false; dashboard.hidden = true; });
</script>
What does the session loader do?
- The request uses cache: "no-store" so the browser asks for current session state.
- An unauthenticated result reveals the Connect GitHub control.
- An authenticated result reveals the dashboard.
- The textContent assignments place API values into existing elements as text.
- The replaceChildren() call clears old email rows before rendering the latest response.
- Each email row is created through document.createElement("li").
- The audit fields display the verified state, PKCE method, granted scope, token type, and server-memory location.
- Save public/index.html.
Before you refresh, which account details do you expect the server's safe projection to reveal?
- Return to the local dashboard in Microsoft Edge.
- Refresh http://127.0.0.1:3000.
You should see your GitHub avatar, display name, profile link, and permitted email records. The audit shows the authorization-code exchange result plus verified state.
You should also see S256, the granted scopes, the token type, and Node.js process memory only.
Dashboard data missing?
Check the server terminal for a failed GitHub request. A stopped server or expired in-memory token prevents the session loader from receiving account data.
Confirm that each selector in the script matches the corresponding element ID in public/index.html.
Help me debug why my OAuth dashboard is not rendering /api/session data.
✔️ Awesome, I've got everything!
Your dashboard file now contains the complete safe rendering flow.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OAuth Security Dashboard</title>
</head>
<body>
<main>
<h1>OAuth Security Dashboard</h1>
<p>Trace a delegated GitHub authorization without exposing credentials to browser code.</p>
<section id="signed-out" hidden><h2>Not connected</h2><p>Authorize read-only access to your GitHub email addresses.</p><a href="/login">Connect GitHub</a></section>
<p id="error" hidden></p>
<section id="dashboard" hidden>
<article><img id="avatar" alt="GitHub avatar" /><h2 id="name"></h2><a id="profile-link" target="_blank" rel="noreferrer"></a></article>
<article><h2>Authorized email data</h2><ul id="email-list"></ul><p id="email-warning" hidden>The required email scope was not granted.</p></article>
<article><h2>OAuth audit</h2><dl><dt>Authorization code exchanged</dt><dd id="code-exchanged"></dd><dt>State validation</dt><dd id="state-result"></dd><dt>PKCE method</dt><dd id="pkce-method"></dd><dt>Granted scopes</dt><dd id="granted-scopes"></dd><dt>Token type</dt><dd id="token-type"></dd><dt>Token location</dt><dd id="token-location"></dd></dl></article>
</section>
</main>
<script>
const signedOut = document.querySelector("#signed-out");
const dashboard = document.querySelector("#dashboard");
const errorBox = document.querySelector("#error");
async function loadSession() {
const response = await fetch("/api/session", { headers: { Accept: "application/json" }, cache: "no-store" });
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? "Unable to load the OAuth session.");
if (!data.authenticated) { signedOut.hidden = false; dashboard.hidden = true; return; }
signedOut.hidden = true; dashboard.hidden = false;
document.querySelector("#avatar").src = data.profile.avatarUrl;
document.querySelector("#name").textContent = data.profile.name || data.profile.login;
const profileLink = document.querySelector("#profile-link");
profileLink.href = data.profile.htmlUrl; profileLink.textContent = `@${data.profile.login}`;
const emailList = document.querySelector("#email-list"); emailList.replaceChildren();
for (const item of data.emails) { const row = document.createElement("li"); const labels = [item.primary ? "primary" : null, item.verified ? "verified" : "unverified", item.visibility ?? "private"].filter(Boolean); row.textContent = `${item.email} (${labels.join(", ")})`; emailList.append(row); }
document.querySelector("#email-warning").hidden = data.audit.emailAccessGranted;
document.querySelector("#code-exchanged").textContent = String(data.audit.authorizationCodeExchanged);
document.querySelector("#state-result").textContent = data.audit.stateVerified ? "verified" : "failed";
document.querySelector("#pkce-method").textContent = data.audit.pkceMethod;
document.querySelector("#granted-scopes").textContent = data.audit.grantedScopes;
document.querySelector("#token-type").textContent = data.audit.tokenType;
document.querySelector("#token-location").textContent = data.audit.tokenLocation;
}
loadSession().catch((error) => { errorBox.textContent = error.message; errorBox.hidden = false; signedOut.hidden = false; dashboard.hidden = true; });
</script>
</body>
</html>
One final check proves that the browser receives useful data without receiving server credentials.
- Press Ctrl+Shift+I in Microsoft Edge to open DevTools.
- Select the Network tool.
- Refresh the dashboard.
- Select the request whose URL ends with /api/session.
- Select the Response tab.
- Confirm that the response contains only profile, email, and audit data.
- Use the Search pane to search for access_token.
- Search for GITHUB_CLIENT_SECRET.
Both searches should return no matches. Your address bar should still show the local dashboard without an access token.
That completes the security boundary. Your Node.js server now retrieves delegated GitHub data while the browser displays only the fields it needs.
Secret mission
Build a Provider-Aware Disconnect
Your dashboard can clear its local token while GitHub still holds the provider authorization. Add a Disconnect control that exposes this difference and links to the provider record for review.
Clean Up Your Resources
Clean Up Your Resources
Your dashboard runs locally with GitHub Free at no ongoing hosting cost. Decide whether to keep your resources available, pause your local server, or delete everything.
Resources you used:
- Local application: The oauth-security-dashboard folder contains your source files. It also contains the secret .env file used by Node.js.
- GitHub OAuth App: The registration named OAuth Security Dashboard stores your homepage URL and callback URL.
- Provider authorization: Your consent record remains at GitHub until you revoke it from the provider review page.
Keep everything running
No action is needed while you are still extending or demonstrating the dashboard. Your local files and GitHub resources remain ready for another session.
- Keep the oauth-security-dashboard folder on your computer.
- Keep .env private.
- Leave the OAuth Security Dashboard registration in your GitHub account.
- Leave the provider authorization in place for future connections.
- Stop the local server with Ctrl+C when you finish working.
Pause - I'll come back to this later
Shut down the local server to free its memory. Your files and GitHub records stay available for your return.
- Switch back to the terminal running server.mjs.
- Press Ctrl+C to stop Node.js.
- Keep the oauth-security-dashboard folder in its current location.
- Keep the OAuth Security Dashboard registration in your GitHub account.
The access token disappears from Node.js process memory when the server stops.
Your provider authorization remains available for reconnecting later.
Delete - I don't want to use this again
Remove every project resource when you want a clean slate.
Deletion is permanent. Your GitHub account remains intact.
- Stop any running Node.js server by pressing Ctrl+C in its terminal.
- Return to the provider review link revealed by Disconnect.
- Use the authorization record's revocation control to remove OAuth Security Dashboard.
- Confirm the authorization removal.
The provider authorization should no longer appear on the review page. This confirms that GitHub no longer retains that consent record.
- Open your profile picture menu on GitHub.
- Select Settings.
- Select Developer settings.
- Select OAuth apps.
- Select OAuth Security Dashboard.
- Use the deletion control on the app settings page.
- Confirm the app deletion.
The OAuth Apps list should no longer show OAuth Security Dashboard.
- Close Visual Studio Code.
- Press the Windows key to open search.
- Type File Explorer into the search bar.
- Press Enter.
- Navigate to the location containing oauth-security-dashboard.
- Select the oauth-security-dashboard folder.
- Press Delete to move the folder to the Recycle Bin.
The folder should disappear from its previous location.
- Open the Recycle Bin.
- Empty the Recycle Bin to remove the project permanently.
You should no longer see the oauth-security-dashboard folder. The secret .env file is gone with it.
Nice Work!
Nice Work!
You did it! Your local dashboard now traces a complete GitHub OAuth 2.0 authorization flow. It displays delegated account data through a token held only by the Node.js server.
You've learned how to:
- Build a working local security dashboard that displays your GitHub profile plus permitted email records. Keep the client secret out of browser JavaScript. Keep the access token on the server.
- Trace the Authorization Code flow from browser consent to callback. Prove that the temporary code fails as a bearer access token through the planned 401 response.
- Protect the redirect with state validation. Apply PKCE with S256 before the server-side token exchange. Use the GitHub REST API with delegated email access.
- Secret Mission: Build a provider-aware disconnect that clears local process-memory state. Preserve a review path for revoking the provider authorization at GitHub.
Ready to quiz yourself?