Web Development

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.

KUZUE
July 3, 2026
7 min read
11 views

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:

  • code
  • state
text
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.

text
POST https://github.com/login/oauth/access_token

The request contains:

  • client_id
  • client_secret
  • code

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.

text
GET https://api.github.com/user

The access token is included in the request headers.

ts
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:

ts
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.

text
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

ts
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.

text
/?login=success

Complete OAuth Flow

text
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.