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
- Stateless vs. stateful: JWT tokens are self-contained and require no server-side lookup, but are hard to revoke. Sessions stored in a database or Redis are revocable but require infrastructure.
- Storage location: Storing tokens in
localStorageexposes them to XSS. HttpOnly cookies are safer but require CSRF protection. - Token lifetime: Short-lived access tokens paired with refresh tokens balance security and usability.
- OAuth scope: Third-party providers reduce password management burden but introduce dependency on external services.
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
- Always use HttpOnly cookies for authentication tokens. Never store them in
localStorageor plain JavaScript-accessible cookies, as this exposes them to XSS attacks. - Set
SecureandSameSiteattributes in production.Secureensures tokens are only sent over HTTPS, andSameSite=LaxorStrictmitigates CSRF. - Use strong, unique secrets for JWT signing. Generate them with a cryptographically secure random generator and store them in environment variables, never in source control.
- Keep access tokens short-lived. Fifteen minutes is a reasonable default. Pair them with longer-lived refresh tokens that are rotated on each use.
- Validate the
stateparameter in every OAuth flow. Without it, attackers can trick users into linking attacker-controlled accounts. - Implement rate limiting on login and refresh endpoints to mitigate brute-force and credential-stuffing attacks.
- Log authentication events such as successful logins, failed attempts, and token refreshes. This is essential for incident response and audit compliance.
- Hash passwords with bcrypt or argon2. Never store plaintext passwords, and never roll your own hashing algorithm.
- Test with Turbopack's fast feedback loop. Use the rapid HMR to iterate on middleware and route handlers, but always run a production build (
next build) to verify behavior in the optimized bundle.
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.