Introduction
When building login and protected pages, one common question is: where to store JWT token in Next.js?
A JWT (JSON Web Token) is a signed string that proves a user is logged in. After a user enters the correct email and password, your server creates a JWT. The application sends this token with future requests so the server can identify the user.
You can technically store a JWT in several places:
localStoragesessionStorage- JavaScript memory/state
- Browser cookies
However, for most production Next.js applications, the best answer to “where to store JWT token in Next.js” is:
Store the JWT in a secure,
HttpOnlycookie set by your server.
This approach keeps the token unavailable to browser JavaScript, which reduces the chance of a token being stolen through an XSS attack. It also works very well with the Next.js App Router, Server Components, Route Handlers, and server-side authentication checks.
In this article, you will learn why secure cookies are usually the best choice, how to implement them step by step, and which common mistakes to avoid.
What Is a JWT Token?

JWT stands for JSON Web Token. It normally contains three parts:
header.payload.signatureCode language: CSS (css)A JWT may contain small pieces of user information, called claims:
{
"sub": "user_123",
"email": "user@example.com",
"role": "admin"
}Code language: JSON / JSON with Comments (json)The server signs the JWT using a secret key. Later, the server verifies that signature before trusting the user information.
Important: a normal JWT is usually signed, not encrypted. Do not put passwords, bank details, or other sensitive information inside it.
Where to Store JWT Token in Next.js: Available Options
Before choosing the right approach, let’s understand the main storage options.
1. localStorage
You may see this pattern in many tutorials:
localStorage.setItem("token", token);Code language: JavaScript (javascript)Then the application reads the token and sends it in an Authorization header:
const token = localStorage.getItem("token");
fetch("/api/profile", {
headers: {
Authorization: `Bearer ${token}`,
},
});Code language: JavaScript (javascript)This is simple, but it has an important security weakness: any JavaScript running on your page can read localStorage.
If an attacker successfully injects malicious JavaScript through an XSS vulnerability, they may be able to read and steal the JWT.
For this reason, localStorage is generally not the preferred answer for where to store JWT token in Next.js production applications.
2. sessionStorage
sessionStorage is similar to localStorage, but it is usually cleared when the browser tab is closed.
sessionStorage.setItem("token", token);Code language: JavaScript (javascript)It may reduce how long the token remains in the browser, but JavaScript can still access it. Therefore, it still has XSS-related risk.
3. In-Memory Storage
You can keep a token only in React state, Zustand, Redux, or another JavaScript variable.
const [token, setToken] = useState<string | null>(null);Code language: JavaScript (javascript)This means the token disappears after a page refresh. It can be useful for a short-lived access token, but it is inconvenient as the main login solution because users would need to log in again after refreshing the page.
4. HttpOnly Cookies — Recommended
For most Next.js applications, store the JWT in an HttpOnly cookie:
Set-Cookie: access_token=your-jwt-here; HttpOnly; Secure; SameSite=LaxCode language: JavaScript (javascript)An HttpOnly cookie cannot be read with browser JavaScript:
document.cookie; // Your HttpOnly JWT will not be available hereCode language: JavaScript (javascript)The browser automatically includes the cookie in requests to your domain, and your Next.js server can read it securely.
According to OWASP guidance on HttpOnly cookies, the HttpOnly attribute prevents browser scripts from accessing the cookie value. You should still protect your app from XSS, but this setting makes token theft harder.
The Recommended Answer: Store JWT in an HttpOnly Cookie
For a typical Next.js App Router application, use this flow:
- The user submits the login form.
- Your Next.js Route Handler validates their credentials.
- Your server creates a short-lived JWT.
- The server saves the JWT in an
HttpOnly,Secure,SameSitecookie. - Protected pages and APIs verify the JWT on the server.
- On logout, the server deletes the cookie.
This keeps your JWT away from localStorage and away from client-side JavaScript.
Step 1: Install a JWT Library
A good modern JWT library for Next.js is jose.
npm install joseCreate a .env.local file:
JWT_SECRET=use-a-long-random-secret-value-hereCode language: PHP (php)Never expose this secret with a NEXT_PUBLIC_ prefix. Variables beginning with NEXT_PUBLIC_ can be included in browser-side code.
Step 2: Create JWT Helper Functions
Create a file named lib/auth.ts.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export type JwtPayload = {
userId: string;
email: string;
role: "user" | "admin";
};
export async function createToken(payload: JwtPayload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
}
export async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, secret);
return payload as JwtPayload;
}Code language: JavaScript (javascript)This code creates a JWT that expires after one hour.
Short-lived tokens are safer than tokens that remain valid for many weeks or months. If a token is leaked, the damage period is smaller.
Step 3: Create a Login Route Handler
Create this file:
app/api/auth/login/route.tsimport { NextResponse } from "next/server";
import { createToken } from "@/lib/auth";
export async function POST(request: Request) {
const { email, password } = await request.json();
// Replace this with a database lookup and hashed password comparison.
const isValidUser =
email === "admin@example.com" && password === "password123";
if (!isValidUser) {
return NextResponse.json(
{ message: "Invalid email or password" },
{ status: 401 }
);
}
const token = await createToken({
userId: "user_123",
email,
role: "admin",
});
const response = NextResponse.json({
message: "Login successful",
});
response.cookies.set({
name: "access_token",
value: token,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60, // 1 hour
});
return response;
}Code language: JavaScript (javascript)The most important part is this:
httpOnly: trueCode language: JavaScript (javascript)This prevents client-side JavaScript from reading the JWT token.
Also note:
secure: process.env.NODE_ENV === "production"Code language: JavaScript (javascript)In production, Secure ensures the browser sends the cookie only over HTTPS. On localhost during development, it is normally disabled so your local login can work.
The MDN documentation for the Set-Cookie header explains cookie attributes such as HttpOnly, Secure, SameSite, Path, and Max-Age.
Step 4: Create a Login Form
Create a simple client component.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(event.currentTarget);
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: formData.get("email"),
password: formData.get("password"),
}),
});
const data = await response.json();
setLoading(false);
if (!response.ok) {
setError(data.message || "Login failed");
return;
}
router.push("/dashboard");
router.refresh();
}
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Login"}
</button>
{error && <p>{error}</p>}
</form>
);
}Code language: JavaScript (javascript)You do not need to manually save the token in the browser. The server sends the cookie, and the browser stores it automatically.
Step 5: Protect a Server-Rendered Page
Create a protected dashboard page:
app/dashboard/page.tsximport { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { verifyToken } from "@/lib/auth";
export default async function DashboardPage() {
const cookieStore = await cookies();
const token = cookieStore.get("access_token")?.value;
if (!token) {
redirect("/login");
}
try {
const user = await verifyToken(token);
return (
<main>
<h1>Welcome, {user.email}</h1>
<p>Your role is: {user.role}</p>
</main>
);
} catch {
redirect("/login");
}
}Code language: JavaScript (javascript)This is one major benefit of using secure cookies in Next.js. The token is available on the server before the page is rendered.
A user cannot access /dashboard simply by changing the client-side UI. Your server verifies the token before showing protected content.
For more authentication architecture guidance, read the official Next.js authentication guide.
Step 6: Protect an API Route
You should protect APIs too. Hiding a dashboard button is not enough.
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { verifyToken } from "@/lib/auth";
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get("access_token")?.value;
if (!token) {
return NextResponse.json(
{ message: "Unauthorized" },
{ status: 401 }
);
}
try {
const user = await verifyToken(token);
if (user.role !== "admin") {
return NextResponse.json(
{ message: "Forbidden" },
{ status: 403 }
);
}
return NextResponse.json({
message: "Admin data loaded successfully",
});
} catch {
return NextResponse.json(
{ message: "Invalid or expired token" },
{ status: 401 }
);
}
}Code language: JavaScript (javascript)Authentication answers: “Who is this user?”
Authorization answers: “Is this user allowed to do this action?”
Always check both when needed.
Step 7: Create a Logout Route
To log out, remove the cookie from the server.
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({
message: "Logged out successfully",
});
response.cookies.set({
name: "access_token",
value: "",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0,
});
return response;
}Code language: JavaScript (javascript)A logout button can call this endpoint:
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const router = useRouter();
async function logout() {
await fetch("/api/auth/logout", {
method: "POST",
});
router.push("/login");
router.refresh();
}
return <button onClick={logout}>Logout</button>;
}Code language: JavaScript (javascript)Best Practices for Storing JWT Token in Next.js
Use HttpOnly cookies
This should be your default choice when deciding where to store JWT token in Next.js. JavaScript cannot access the token, reducing token theft risk from many XSS attacks.
Use Secure in production
Always use HTTPS in production.
secure: trueCode language: JavaScript (javascript)For local development, conditionally enable it based on the environment.
Use SameSite
For most same-site applications, start with:
sameSite: "lax"Code language: JavaScript (javascript)This provides useful CSRF protection while supporting normal navigation.
Use SameSite: "none" only when your frontend and backend must work across different sites. It requires Secure: true and needs stronger CSRF protection.
Keep the JWT small
Do not store full user profiles, passwords, payment details, or private data inside a JWT. Keep only the minimum information needed, such as user ID and role.
Set a short expiration time
Use a short-lived access token, such as 15 minutes to one hour. For longer sessions, use a carefully designed refresh-token flow or a server-managed session.
Verify tokens on the server
Never trust role information received only from the client. Always verify the JWT and check permissions in your server-side code.
Keep your secret safe
Store JWT_SECRET in environment variables. Add .env.local to .gitignore. Never commit secrets to GitHub.
Common Mistakes to Avoid
Storing JWT in localStorage by default
This is the most common mistake. It is easy, but your token becomes readable by browser JavaScript.
Trying to read an HttpOnly cookie in a client component
This will not work:
document.cookieCode language: JavaScript (javascript)That is expected. The purpose of an HttpOnly cookie is to keep it inaccessible to JavaScript. Read and verify it in Route Handlers, Server Components, Server Actions, or other server-side code.
Forgetting to verify the JWT
Reading a cookie is not enough:
const token = cookieStore.get("access_token")?.value;Code language: JavaScript (javascript)You must verify its signature and expiry:
const user = await verifyToken(token);Code language: JavaScript (javascript)Using a very long token lifetime
A token that lasts for 30 days is risky. If leaked, it remains usable for a long time. Prefer short expiry times and consider refresh tokens only if your application needs persistent login.
Putting sensitive information in the JWT payload
Remember that JWT payloads can often be decoded by anyone who has the token. Signing prevents tampering, but it does not make the data secret.
Protecting only frontend pages
A user may call your API directly from Postman, browser tools, or another script. Every protected API route must verify authentication and authorization independently.
Conclusion
So, where to store JWT token in Next.js?
For most real-world Next.js applications, store the JWT in a secure HttpOnly cookie. This approach works well with the App Router and protects the token from direct JavaScript access.
Avoid using localStorage as the default place for authentication tokens. Although it is easy to implement, it exposes the JWT to client-side JavaScript and increases risk if an XSS vulnerability appears.
Use HttpOnly, Secure, and SameSite cookie settings, keep token expiry short, verify JWTs on the server, and protect both pages and APIs. With these practices, your Next.js authentication flow will be safer, cleaner, and easier to maintain.