← Back to DevBytes

Turbopack Authentication: JWT, Sessions, and OAuth Integration

Introduction to Turbopack Authentication

Before diving into authentication strategies, it is important to clarify a common misconception: Turbopack itself is not an authentication framework. Turbopack is a high-performance, Rust-based bundler and dev server for Next.js. Authentication — whether JWT, sessions, or OAuth — is implemented at the application layer using Next.js APIs, middleware, and server actions. However, because Turbopack dramatically improves local development speed and supports Next.js features like server components, route handlers, and middleware out of the box, it is the ideal environment for building and iterating on secure authentication flows.

This tutorial walks through building a complete authentication system in a Next.js application running on Turbopack. We will cover stateless JWT authentication, stateful session cookies, and OAuth integration with a third-party provider. By the end, you will have a production-ready foundation you can extend for any application.

Why Authentication Architecture Matters

Choosing the right authentication strategy affects security, scalability, and developer experience. A poorly designed auth system can lead to token leakage, session fixation, CSRF vulnerabilities, or performance bottlenecks. When you run on Turbopack, fast hot module replacement lets you iterate quickly on these flows, but the underlying principles remain the same regardless of your bundler.

Key Considerations

Project Setup

Start by creating a new Next.js application with Turbopack enabled. As of Next.js 15, Turbopack is the default dev server, but you can explicitly opt in using the --turbopack flag for clarity.

npx create-next-app@latest turbopack-auth
cd turbopack-auth
npm install jose cookie passport passport-google-oauth20
npm install -D @types/passport @types/passport-google-oauth20

We use jose for JWT signing and verification because it is a modern, Web Crypto-compatible library that works in both Edge and Node.js runtimes. The cookie library simplifies serializing Set-Cookie headers.

JWT Authentication

JWT (JSON Web Token) is a compact, URL-safe token format that encodes claims about a user. A JWT is signed with a secret key so the server can verify its integrity without storing it. This makes JWT ideal for stateless authentication, especially in distributed or serverless environments.

Creating a JWT Utility Module

Create a dedicated module to handle signing and verification. Centralizing this logic prevents subtle bugs and makes it easy to swap implementations later.

// lib/jwt.ts
import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET || "dev-secret-change-me"
);

export interface JWTPayload {
  userId: string;
  email: string;
  role: string;
}

export async function signToken(payload: JWTPayload): Promise<string> {
  return new SignJWT({ ...payload })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setIssuer("turbopack-auth")
    .setAudience("turbopack-auth-users")
    .setExpirationTime("15m")
    .sign(secret);
}

export async function verifyToken(token: string): Promise<JWTPayload | null> {
  try {
    const { payload } = await jwtVerify(token, secret, {
      issuer: "turbopack-auth",
      audience: "turbopack-auth-users",
    });
    return payload as unknown as JWTPayload;
  } catch {
    return null;
  }
}

Notice we set an issuer and audience. These claims prevent token reuse across different services and should always be configured in production.

Issuing Tokens on Login

Create a route handler that accepts credentials, validates them, and issues a JWT stored in an HttpOnly cookie.

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { serialize } from "cookie";
import { signToken } from "@/lib/jwt";

export async function POST(req: NextRequest) {
  const { email, password } = await req.json();

  // Replace with real user lookup and password hashing
  const user = await findUserByEmail(email);
  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    return NextResponse.json(
      { error: "Invalid credentials" },
      { status: 401 }
    );
  }

  const token = await signToken({
    userId: user.id,
    email: user.email,
    role: user.role,
  });

  const cookie = serialize("auth_token", token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 15, // 15 minutes
  });

  return NextResponse.json(
    { ok: true },
    { headers: { "Set-Cookie": cookie } }
  );
}

Protecting Routes with Middleware

Next.js middleware runs on the Edge runtime, which is fully supported by Turbopack. Use it to verify the JWT on every request before it reaches your page or API route.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyToken } from "@/lib/jwt";

const publicPaths = ["/", "/login", "/register", "/api/auth/login"];

export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  if (publicPaths.some((p) => pathname.startsWith(p))) {
    return NextResponse.next();
  }

  const token = req.cookies.get("auth_token")?.value;
  if (!token) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  const payload = await verifyToken(token);
  if (!payload) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  // Attach user info to headers for downstream handlers
  const requestHeaders = new Headers(req.headers);
  requestHeaders.set("x-user-id", payload.userId);
  requestHeaders.set("x-user-role", payload.role);

  return NextResponse.next({
    request: { headers: requestHeaders },
  });
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Session-Based Authentication

Sessions are an alternative to JWT where the server stores session state and issues an opaque session ID to the client. This approach is preferable when you need immediate revocation, audit trails, or fine-grained session control.

Session Store Design

For production, use Redis or a database table. For this tutorial, we will use an in-memory store with a clear interface so you can swap implementations easily.

// lib/session-store.ts
import { randomUUID } from "crypto";

interface SessionData {
  userId: string;
  email: string;
  role: string;
  createdAt: number;
  expiresAt: number;
}

const store = new Map<string, SessionData>();

const SESSION_TTL = 1000 * 60 * 60 * 24 * 7; // 7 days

export async function createSession(user: {
  userId: string;
  email: string;
  role: string;
}): Promise<string> {
  const sessionId = randomUUID();
  const now = Date.now();
  store.set(sessionId, {
    ...user,
    createdAt: now,
    expiresAt: now + SESSION_TTL,
  });
  return sessionId;
}

export async function getSession(
  sessionId: string
): Promise<SessionData | null> {
  const session = store.get(sessionId);
  if (!session) return null;
  if (Date.now() > session.expiresAt) {
    store.delete(sessionId);
    return null;
  }
  return session;
}

export async function destroySession(sessionId: string): Promise<void> {
  store.delete(sessionId);
}

Session Login and Logout Handlers

// app/api/auth/session/route.ts
import { NextRequest, NextResponse } from "next/server";
import { serialize } from "cookie";
import { createSession, destroySession, getSession } from "@/lib/session-store";

export async function POST(req: NextRequest) {
  const { email, password } = await req.json();

  const user = await findUserByEmail(email);
  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
  }

  const sessionId = await createSession({
    userId: user.id,
    email: user.email,
    role: user.role,
  });

  const cookie = serialize("session_id", sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 7,
  });

  return NextResponse.json({ ok: true }, { headers: { "Set-Cookie": cookie } });
}

export async function GET(req: NextRequest) {
  const sessionId = req.cookies.get("session_id")?.value;
  if (!sessionId) {
    return NextResponse.json({ user: null }, { status: 401 });
  }
  const session = await getSession(sessionId);
  if (!session) {
    return NextResponse.json({ user: null }, { status: 401 });
  }
  return NextResponse.json({
    user: { id: session.userId, email: session.email, role: session.role },
  });
}

export async function DELETE(req: NextRequest) {
  const sessionId = req.cookies.get("session_id")?.value;
  if (sessionId) {
    await destroySession(sessionId);
  }
  const cookie = serialize("session_id", "", {
    httpOnly: true,
    path: "/",
    maxAge: 0,
  });
  return NextResponse.json({ ok: true }, { headers: { "Set-Cookie": cookie } });
}

CSRF Protection for Sessions

Because sessions rely on cookies that are automatically sent with requests, you must protect against CSRF. A common pattern is the double-submit cookie: issue a CSRF token cookie and require it to match a header on mutating requests.

// lib/csrf.ts
import { randomBytes } from "crypto";
import { NextRequest, NextResponse } from "next/server";

export function issueCsrfToken(): string {
  return randomBytes(32).toString("hex");
}

export function validateCsrf(req: NextRequest): boolean {
  const cookieToken = req.cookies.get("csrf_token")?.value;
  const headerToken = req.headers.get("x-csrf-token");
  if (!cookieToken || !headerToken) return false;
  return cookieToken === headerToken;
}

OAuth Integration

OAuth lets users authenticate through a trusted provider like Google, GitHub, or Auth0, removing the need for your application to handle passwords. We will integrate Google OAuth using a manual flow that works cleanly with Next.js route handlers and Turbopack.

Configuring OAuth Credentials

Create a project in the Google Cloud Console, enable the Google+ API, and create OAuth 2.0 credentials. Add the following environment variables to your .env.local file:

GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/oauth/callback

Initiating the OAuth Flow

// app/api/auth/oauth/google/route.ts
import { NextResponse } from "next/server";
import { randomBytes } from "crypto";
import { serialize } from "cookie";

export async function GET() {
  const state = randomBytes(16).toString("hex");

  const params = new URLSearchParams({
    client_id: process.env.GOOGLE_CLIENT_ID!,
    redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
    response_type: "code",
    scope: "openid email profile",
    state,
    access_type: "offline",
    prompt: "consent",
  });

  const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;

  const cookie = serialize("oauth_state", state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 10, // 10 minutes
  });

  return NextResponse.redirect(authUrl, {
    headers: { "Set-Cookie": cookie },
  });
}

The state parameter prevents CSRF attacks during the OAuth handshake. We store it in a short-lived cookie and verify it on callback.

Handling the OAuth Callback

// app/api/auth/oauth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { serialize } from "cookie";
import { signToken } from "@/lib/jwt";

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const code = searchParams.get("code");
  const state = searchParams.get("state");
  const storedState = req.cookies.get("oauth_state")?.value;

  if (!code || !state || state !== storedState) {
    return NextResponse.redirect(new URL("/login?error=oauth", req.url));
  }

  // Exchange code for tokens
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      code,
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
      grant_type: "authorization_code",
    }),
  });

  if (!tokenRes.ok) {
    return NextResponse.redirect(new URL("/login?error=token", req.url));
  }

  const tokens = await tokenRes.json();

  // Fetch user profile
  const profileRes = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  });
  const profile = await profileRes.json();

  // Upsert user in your database
  const user = await upsertOAuthUser({
    provider: "google",
    providerId: profile.sub,
    email: profile.email,
    name: profile.name,
  });

  // Issue your own JWT
  const jwt = await signToken({
    userId: user.id,
    email: user.email,
    role: user.role,
  });

  const authCookie = serialize("auth_token", jwt, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 15,
  });

  const stateCookie = serialize("oauth_state", "", {
    httpOnly: true,
    path: "/",
    maxAge: 0,
  });

  return NextResponse.redirect(new URL("/dashboard", req.url), {
    headers: {
      "Set-Cookie": [authCookie, stateCookie].join(", "),
    },
  });
}

This pattern — exchanging an authorization code for provider tokens, fetching the user profile, then issuing your own application JWT — is the standard OAuth 2.0 authorization code flow. It works identically for GitHub, Auth0, or any other provider; only the endpoints and scopes change.

Combining Strategies with Refresh Tokens

A robust system often combines JWT for short-lived access with refresh tokens for session extension. The access token is sent on every request and expires quickly. The refresh token is longer-lived, stored in a more restrictive cookie, and used only at a dedicated endpoint to mint new access tokens.

// lib/refresh.ts
import { SignJWT, jwtVerify } from "jose";

const refreshSecret = new TextEncoder().encode(
  process.env.REFRESH_SECRET || "dev-refresh-secret"
);

export async function signRefreshToken(userId: string): Promise<string> {
  return new SignJWT({ userId })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("7d")
    .sign(refreshSecret);
}

export async function verifyRefreshToken(
  token: string
): Promise<{ userId: string } | null> {
  try {
    const { payload } = await jwtVerify(token, refreshSecret);
    return { userId: payload.userId as string };
  } catch {
    return null;
  }
}
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from "next/server";
import { serialize } from "cookie";
import { verifyRefreshToken, signRefreshToken } from "@/lib/refresh";
import { signToken } from "@/lib/jwt";

export async function POST(req: NextRequest) {
  const refreshToken = req.cookies.get("refresh_token")?.value;
  if (!refreshToken) {
    return NextResponse.json({ error: "No refresh token" }, { status: 401 });
  }

  const result = await verifyRefreshToken(refreshToken);
  if (!result) {
    return NextResponse.json({ error: "Invalid refresh token" }, { status: 401 });
  }

  const user = await getUserById(result.userId);
  if (!user) {
    return NextResponse.json({ error: "User not found" }, { status: 401 });
  }

  const accessToken = await signToken({
    userId: user.id,
    email: user.email,
    role: user.role,
  });

  // Rotate refresh token for added security
  const newRefreshToken = await signRefreshToken(user.id);

  const accessCookie = serialize("auth_token", accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 15,
  });

  const refreshCookie = serialize("refresh_token", newRefreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
    path: "/api/auth/refresh",
    maxAge: 60 * 60 * 24 * 7,
  });

  return NextResponse.json(
    { ok: true },
    { headers: { "Set-Cookie": [accessCookie, refreshCookie].join(", ") } }
  );
}

Notice the refresh token cookie is scoped to /api/auth/refresh only. This means the browser will not send it on any other request, reducing the attack surface significantly.

Best Practices

Conclusion

Building authentication in a Turbopack-powered Next.js application gives you the best of both worlds: a blazing-fast development experience and a robust, standards-based auth architecture. By combining short-lived JWTs with refresh token rotation, HttpOnly cookies for secure storage, CSRF protection for session-based flows, and the OAuth authorization code pattern for third-party login, you create a layered defense that addresses the most common web security threats. The code in this tutorial is a starting point — adapt the session store to Redis or your database, add rate limiting and audit logging, and always review your threat model before shipping to production. With these foundations in place, your application will be ready to handle real users safely and scale as your authentication requirements grow.

— Ad —

Google AdSense will appear here after approval

← Back to all articles