Understanding GitHub OAuth Authentication in Next.js (Part 2)
In the previous article, we built the authorization route and redirected users to GitHub for authentication. Now we'll handle the callback from GitHub, exchange the authorization code for an access token, retrieve the user's information, create or link the account in our database, and finally create a session.
In the previous article, we built the authorization route and redirected users to GitHub for authentication.
Now we'll handle the callback from GitHub, exchange the authorization code for an access token, retrieve the user's information, create or link the account in our database, and finally create a session.
What Happens After the User Clicks Authorize?
Once the user approves your application, GitHub redirects the browser back to the callback URL you registered earlier.
The callback URL contains two important query parameters:
codestate
GET /Oauth/github/callback?code=abc123&state=xyz456
The code is a short-lived authorization code (typically valid for about 10 minutes).
The state is the same random string we generated in Part 1 and stored in a cookie before redirecting the user to GitHub.
The first thing our callback route should do is validate the state.
If the state received from GitHub does not match the one stored in our cookie, we should immediately abort the request because it may be a Cross-Site Request Forgery (CSRF) attack.
Exchanging the Authorization Code
After validating the state, we can exchange the authorization code for an access token.
Unlike the callback request, this is a server-to-server POST request made directly to GitHub's token endpoint.
POST https://github.com/login/oauth/access_token
The request contains:
client_idclient_secretcode
If everything is valid, GitHub responds with an Access Token.
Retrieving the GitHub User
Once we have an access token, we can request the user's profile.
GET https://api.github.com/user
The access token is included in the request headers.
Authorization: token ACCESS_TOKEN
This endpoint returns information such as:
- GitHub ID
- Username
- Avatar
- Name
- Email (sometimes)
Why Do We Make Another Request for the Email?
Many OAuth providers include the user's email directly in the profile response.
GitHub is a little different.
Users can choose to keep their email address private. When they do, the profile endpoint returns:
githubUser.email === null
Because of this, we cannot rely on the profile endpoint to always contain an email address.
GitHub provides another endpoint specifically for retrieving a user's email addresses.
GET https://api.github.com/user/emails
This endpoint returns both public and private email addresses.
Each email object contains information such as:
- Whether it is the primary email.
- Whether it has been verified.
Our application simply selects the email that is both primary and verified.
This is actually an optimization.
If githubUser.email already contains a value, we use it immediately. Otherwise, we make a second request to retrieve the correct email.
Callback Route
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import prisma from '@/lib/db/prisma';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
const cookieStore = await cookies();
const savedState = cookieStore.get('oauth_state')?.value;
// Validate the OAuth state
if (!state || !savedState || state !== savedState) {
return NextResponse.json(
{
success: false,
error:
'OAuth State Validation Failed. Possible CSRF attempt.',
},
{ status: 400 }
);
}
cookieStore.delete('oauth_state');
if (!code) {
return NextResponse.json(
{
success: false,
error: 'Authorization code is missing.',
},
{ status: 400 }
);
}
const clientId = process.env.GITHUB_CLIENT_ID;
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
// Exchange authorization 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: clientId,
client_secret: clientSecret,
code,
}),
}
);
const tokenData = await tokenResponse.json();
if (tokenData.error) {
return NextResponse.json(
{
success: false,
error: tokenData.error_description,
},
{ status: 400 }
);
}
const accessToken = tokenData.access_token;
// Request GitHub profile
const userProfileResponse = await fetch(
'https://api.github.com/user',
{
headers: {
Authorization: `token ${accessToken}`,
'User-Agent': 'NextJS-Auth-Playground',
},
}
);
const githubUser = await userProfileResponse.json();
if (!githubUser.id) {
return NextResponse.json(
{
success: false,
error: 'Unable to retrieve GitHub profile.',
},
{ status: 400 }
);
}
// Retrieve email addresses
const emailsResponse = await fetch(
'https://api.github.com/user/emails',
{
headers: {
Authorization: `token ${accessToken}`,
'User-Agent': 'NextJS-Auth-Playground',
},
}
);
const emails = await emailsResponse.json();
let primaryEmail = githubUser.email;
if (Array.isArray(emails)) {
const primaryEmailObj = emails.find(
(email: any) => email.primary && email.verified
);
if (primaryEmailObj) {
primaryEmail = primaryEmailObj.email;
}
}
if (!primaryEmail) {
return NextResponse.json(
{
success: false,
error: 'No verified email address found.',
},
{ status: 400 }
);
}
const githubId = githubUser.id.toString();
// Find or create the user
let user = await prisma.user.findUnique({
where: {
githubId,
},
});
if (!user) {
const existingUser = await prisma.user.findUnique({
where: {
email: primaryEmail,
},
});
if (existingUser) {
user = await prisma.user.update({
where: {
id: existingUser.id,
},
data: {
githubId,
isVerified: true,
},
});
} else {
user = await prisma.user.create({
data: {
email: primaryEmail,
githubId,
isVerified: true,
passwordHash: '',
},
});
}
}
// Create a session
const sessionToken = crypto.randomUUID();
const expiresAt = new Date(
Date.now() + 24 * 60 * 60 * 1000
);
await prisma.session.create({
data: {
sessionToken,
userId: user.id,
expiresAt,
},
});
cookieStore.set('session_token', sessionToken, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
expires: expiresAt,
});
return NextResponse.redirect(
`${request.nextUrl.origin}/?login=success`
);
} catch (error) {
console.error(error);
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
);
}
}
Creating or Linking Accounts
Once we retrieve the user's verified email and GitHub ID, we check our own database.
There are three possible scenarios:
1. The GitHub account already exists
The user has signed in before.
We simply create a new session and log them in.
2. An account already exists with the same email
Perhaps the user originally signed up using email and password.
Instead of creating another account, we simply attach the GitHub ID to the existing account and mark it as verified.
This process is known as Account Linking.
3. The user does not exist
We create a brand new account using the GitHub information, then create a session for them.
Creating the Session
Once the user has been identified, we create a new session in our database.
We generate a random session token, save it to the database, and send it back to the browser inside an HTTP-only cookie.
From this point onward, every request from the browser automatically includes the session cookie, allowing our application to recognize the user without asking them to sign in again.
Finally, we redirect the user back to the homepage.
/?login=success
Complete OAuth Flow
User
│
▼
Clicks "Continue with GitHub"
│
▼
Authorize Route
│
▼
GitHub Login
│
▼
User Grants Permission
│
▼
GitHub Callback
│
▼
Validate State
│
▼
Exchange Code for Access Token
│
▼
Retrieve GitHub Profile
│
▼
Retrieve Verified Email
│
▼
Find or Create User
│
▼
Create Session
│
▼
Redirect User
Conclusion
Congratulations! 🎉
You have now implemented GitHub OAuth authentication completely from scratch.
More importantly, you now understand what OAuth libraries such as Auth.js, NextAuth.js, and Better Auth are doing behind the scenes.
Once you understand this flow, integrating other OAuth providers such as Google, Discord, Microsoft, Facebook, or LinkedIn becomes much easier because they all follow the same OAuth principles with only minor differences in endpoints and scopes.
Comments (0)
No comments yet. Be the first to share your thoughts.