Skip to main content
Back to Blog
Tutorials
3 min read
January 8, 2025

How to Implement OAuth Providers From Scratch in Next.js

Build a full OAuth 2.0 flow from scratch with PKCE, state parameters, token exchange, and session management without third-party auth libraries.

Ryel Banfield

Founder & Lead Developer

Auth libraries are great, but understanding the OAuth 2.0 flow is important. Here is how to build it from scratch.

PKCE Helpers

// lib/oauth/pkce.ts
import { randomBytes, createHash } from "crypto";

export function generateCodeVerifier(): string {
  return randomBytes(32)
    .toString("base64url")
    .replace(/[^a-zA-Z0-9-._~]/g, "")
    .slice(0, 128);
}

export function generateCodeChallenge(verifier: string): string {
  return createHash("sha256").update(verifier).digest("base64url");
}

export function generateState(): string {
  return randomBytes(16).toString("hex");
}

Provider Configuration

// lib/oauth/providers.ts
interface OAuthProvider {
  name: string;
  authorizationUrl: string;
  tokenUrl: string;
  userInfoUrl: string;
  clientId: string;
  clientSecret: string;
  scopes: string[];
  mapUser: (data: Record<string, unknown>) => OAuthUser;
}

interface OAuthUser {
  id: string;
  email: string;
  name: string;
  avatar?: string;
}

const providers: Record<string, OAuthProvider> = {
  google: {
    name: "Google",
    authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
    tokenUrl: "https://oauth2.googleapis.com/token",
    userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    scopes: ["openid", "email", "profile"],
    mapUser: (data) => ({
      id: data.id as string,
      email: data.email as string,
      name: data.name as string,
      avatar: data.picture as string,
    }),
  },
  github: {
    name: "GitHub",
    authorizationUrl: "https://github.com/login/oauth/authorize",
    tokenUrl: "https://github.com/login/oauth/access_token",
    userInfoUrl: "https://api.github.com/user",
    clientId: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    scopes: ["read:user", "user:email"],
    mapUser: (data) => ({
      id: String(data.id),
      email: data.email as string,
      name: (data.name ?? data.login) as string,
      avatar: data.avatar_url as string,
    }),
  },
};

export function getProvider(name: string): OAuthProvider {
  const provider = providers[name];
  if (!provider) throw new Error(`Unknown provider: ${name}`);
  return provider;
}

Authorization Route

// app/api/auth/[provider]/route.ts
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getProvider } from "@/lib/oauth/providers";
import {
  generateCodeVerifier,
  generateCodeChallenge,
  generateState,
} from "@/lib/oauth/pkce";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ provider: string }> },
) {
  const { provider: providerName } = await params;
  const provider = getProvider(providerName);

  const state = generateState();
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = generateCodeChallenge(codeVerifier);

  const cookieStore = await cookies();

  // Store state and verifier in httpOnly cookies
  cookieStore.set("oauth_state", state, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 600, // 10 minutes
    path: "/",
  });

  cookieStore.set("oauth_code_verifier", codeVerifier, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 600,
    path: "/",
  });

  const authUrl = new URL(provider.authorizationUrl);
  authUrl.searchParams.set("client_id", provider.clientId);
  authUrl.searchParams.set(
    "redirect_uri",
    `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/${providerName}/callback`,
  );
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("scope", provider.scopes.join(" "));
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");

  redirect(authUrl.toString());
}

Callback Route

// app/api/auth/[provider]/callback/route.ts
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getProvider } from "@/lib/oauth/providers";
import { createSession } from "@/lib/session";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ provider: string }> },
) {
  const { provider: providerName } = await params;
  const provider = getProvider(providerName);
  const url = new URL(request.url);

  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const error = url.searchParams.get("error");

  if (error) {
    redirect(`/login?error=${encodeURIComponent(error)}`);
  }

  const cookieStore = await cookies();
  const storedState = cookieStore.get("oauth_state")?.value;
  const codeVerifier = cookieStore.get("oauth_code_verifier")?.value;

  // Clean up cookies
  cookieStore.delete("oauth_state");
  cookieStore.delete("oauth_code_verifier");

  // Validate state to prevent CSRF
  if (!state || !storedState || state !== storedState) {
    redirect("/login?error=invalid_state");
  }

  if (!code || !codeVerifier) {
    redirect("/login?error=missing_code");
  }

  // Exchange code for tokens
  const tokenResponse = await fetch(provider.tokenUrl, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Accept: "application/json",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/${providerName}/callback`,
      client_id: provider.clientId,
      client_secret: provider.clientSecret,
      code_verifier: codeVerifier,
    }),
  });

  if (!tokenResponse.ok) {
    redirect("/login?error=token_exchange_failed");
  }

  const tokens = await tokenResponse.json();

  // Fetch user info
  const userResponse = await fetch(provider.userInfoUrl, {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  });

  if (!userResponse.ok) {
    redirect("/login?error=user_info_failed");
  }

  const userData = await userResponse.json();
  const user = provider.mapUser(userData);

  // Create session
  await createSession({
    userId: user.id,
    email: user.email,
    name: user.name,
    avatar: user.avatar,
    provider: providerName,
  });

  redirect("/dashboard");
}

Session Management

// lib/session.ts
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";

const secret = new TextEncoder().encode(process.env.SESSION_SECRET!);

interface SessionPayload {
  userId: string;
  email: string;
  name: string;
  avatar?: string;
  provider: string;
}

export async function createSession(payload: SessionPayload) {
  const token = await new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("7d")
    .sign(secret);

  const cookieStore = await cookies();
  cookieStore.set("session", token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7,
    path: "/",
  });
}

export async function getSession(): Promise<SessionPayload | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get("session")?.value;
  if (!token) return null;

  try {
    const { payload } = await jwtVerify(token, secret);
    return payload as unknown as SessionPayload;
  } catch {
    return null;
  }
}

export async function destroySession() {
  const cookieStore = await cookies();
  cookieStore.delete("session");
}

Login Buttons

export function LoginButtons() {
  return (
    <div className="flex flex-col gap-3">
      <a
        href="/api/auth/google"
        className="inline-flex items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium hover:bg-muted transition-colors"
      >
        Sign in with Google
      </a>
      <a
        href="/api/auth/github"
        className="inline-flex items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium hover:bg-muted transition-colors"
      >
        Sign in with GitHub
      </a>
    </div>
  );
}

Need Secure Authentication?

We build secure authentication flows for web applications. Contact us to protect your users.

OAuthauthenticationNext.jssecurityPKCEtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles