The Callback Handler
What the callback receives
After the user approves your app on GitHub, GitHub redirects them to:
http://localhost:3000/auth/github/callback?code=abc123def456&state=random_string Your server needs to:
- Read the
codeandstatefrom the URL - Verify the state
- Exchange the code for an access token
The callback route
Add to src/routes/github.ts:
// src/routes/github.ts
import { route, group } from "@hectoday/http";
import { env } from "../env.js";
import { createState, verifyState } from "../oauth-state.js";
export const githubRoutes = group([
route.get("/auth/github", {
resolve: (c) => {
const state = createState();
const params = new URLSearchParams({
client_id: env.githubClientId,
redirect_uri: `${env.baseUrl}/auth/github/callback`,
scope: "read:user user:email",
state,
});
return Response.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
},
}),
route.get("/auth/github/callback", {
resolve: async (c) => {
const url = new URL(c.request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
// Step 1: Verify state
if (!state || !verifyState(state)) {
return Response.json({ error: "Invalid state" }, { status: 403 });
}
// Step 2: Check for errors from GitHub
const error = url.searchParams.get("error");
if (error) {
return Response.json({ error: `GitHub authorization failed: ${error}` }, { status: 400 });
}
if (!code) {
return Response.json({ error: "Missing authorization code" }, { status: 400 });
}
// Step 3: Exchange code for access token
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
client_id: env.githubClientId,
client_secret: env.githubClientSecret,
code,
redirect_uri: `${env.baseUrl}/auth/github/callback`,
}),
});
const tokenData = await tokenResponse.json();
if (tokenData.error) {
return Response.json(
{ error: `Token exchange failed: ${tokenData.error}` },
{ status: 400 },
);
}
const accessToken = tokenData.access_token as string;
// TODO: fetch user profile and create session
return Response.json({ message: "Token received", accessToken });
},
}),
]); Let’s walk through each step.
Step 1: Verify state
const state = url.searchParams.get("state");
if (!state || !verifyState(state)) {
return Response.json({ error: "Invalid state" }, { status: 403 });
} We extract the state from the URL and pass it to verifyState. If the state is missing, expired, or already used, we reject the request with 403. This stops the CSRF attack described in the previous lesson.
Step 2: Check for errors
If the user denies consent (clicks “Cancel” on GitHub’s page), GitHub redirects back with an error parameter instead of a code:
/auth/github/callback?error=access_denied&state=random_string We check for this and return a meaningful error.
Step 3: Exchange the code for a token
This is the server-to-server POST request. We use the standard fetch API to call GitHub’s token endpoint:
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
client_id: env.githubClientId,
client_secret: env.githubClientSecret,
code,
redirect_uri: `${env.baseUrl}/auth/github/callback`,
}),
}); The accept: "application/json" header is important. Without it, GitHub returns the token as a URL-encoded string (access_token=gho_...&token_type=bearer). With the header, GitHub returns JSON, which is easier to parse.
The client secret is in the request body. This is the only request in the entire flow where the secret is sent. It goes directly from your server to GitHub. The browser never sees it.
Token exchange errors
The exchange can fail for several reasons:
- The code has already been used (codes are single-use)
- The code has expired (typically 10 minutes)
- The client_secret is wrong
- The redirect_uri does not match
All of these produce an error field in the response. We check for it and return a clear error message.
What we have so far
The accessToken is currently returned in the response body. This is temporary (and insecure in production). In the next lesson, we will use it to fetch the user’s profile, then discard it.
[!WARNING] Never return an access token to the browser in a real application. The token grants access to the user’s GitHub data. It should stay server-side. We return it here only to verify the exchange works while developing.
Try it out
- Visit
http://localhost:3000 - Click “Log in with GitHub”
- Authorize the app on GitHub
- You should be redirected back and see
{ "message": "Token received", "accessToken": "gho_..." }
If you see the token, the OAuth flow is working. The next step is using it.
Exercises
Exercise 1: After completing the flow once, copy the callback URL from your browser’s address bar and paste it into a new tab. You should get “Invalid state” because the state has already been verified and deleted (single-use). This confirms the replay protection is working.
Exercise 2: Look at the tokenData response from GitHub. What other fields does it include besides access_token? You should see token_type (always “bearer”) and scope (the scopes that were granted).
Why does the token exchange include the redirect_uri, even though the redirect already happened?
What does the `accept: application/json` header do in the token exchange request?