V0 generates authentication flows that work initially but break when JWT tokens expire because the AI omits token refresh logic and session validation middleware. Fix this by implementing automatic token refresh using Next.js middleware, storing tokens in httpOnly cookies instead of localStorage, and adding a session provider that checks token validity before every protected request.
Why tokens expire and break V0 auth flows
V0 generates authentication code that handles the initial login flow correctly but ignores what happens after the token expires. JWTs have a limited lifetime (typically 1 hour for access tokens), and when they expire, API calls return 401 errors and protected pages become inaccessible. V0 commonly stores tokens in localStorage (which fails during SSR), skips refresh token logic entirely, and does not implement Next.js middleware to validate sessions on navigation. The result is that users appear logged in based on stale UI state but every server request fails silently.
- V0 stores JWT tokens in localStorage without checking expiration before API calls
- No refresh token rotation logic — when the access token expires, the user is stuck
- Missing Next.js middleware to validate session tokens on each route navigation
- V0 accesses localStorage during server-side rendering, causing ReferenceError: localStorage is not defined
- Token expiration timestamps are not compared against the current time before making authenticated requests
Error messages you might see
ReferenceError: localStorage is not definedV0 generated code that reads the auth token from localStorage during server-side rendering. localStorage is only available in the browser. Use useEffect to access it on the client or switch to httpOnly cookies.
Error: 401 Unauthorized — {"message":"Token has expired"}The JWT access token has passed its expiration time. The application needs to use a refresh token to obtain a new access token before retrying the request.
Error when checking for token expirationV0 generated a token check that compares the expiration timestamp incorrectly, often using seconds vs. milliseconds mismatch between JWT exp claim (seconds) and Date.now() (milliseconds).
Before you start
- A V0 project with authentication that stops working after the user is idle for a period
- Understanding of JWT structure (header, payload with exp claim, signature)
- An auth provider configured (NextAuth.js, Supabase Auth, Firebase Auth, or custom JWT)
How to fix it
Replace localStorage token storage with httpOnly cookies
localStorage is not accessible during SSR and is vulnerable to XSS attacks. httpOnly cookies are sent automatically with every request, work during server-side rendering, and cannot be read by client-side JavaScript.
Replace localStorage token storage with httpOnly cookies
localStorage is not accessible during SSR and is vulnerable to XSS attacks. httpOnly cookies are sent automatically with every request, work during server-side rendering, and cannot be read by client-side JavaScript.
Move token storage from localStorage to httpOnly cookies set by your API route or auth provider. In Next.js App Router, use the cookies() function in Server Components and API routes to read the token.
// V0 generated — breaks during SSRconst token = localStorage.getItem("auth_token")const response = await fetch("/api/data", { headers: { Authorization: `Bearer ${token}` },})// app/api/auth/login/route.tsimport { cookies } from "next/headers"import { NextResponse } from "next/server"export async function POST(request: Request) { const { email, password } = await request.json() // ... validate credentials and get tokens const { accessToken, refreshToken } = await authenticate(email, password) const cookieStore = await cookies() cookieStore.set("access_token", accessToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 3600, // 1 hour path: "/", }) cookieStore.set("refresh_token", refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 604800, // 7 days path: "/", }) return NextResponse.json({ success: true })}Expected result: Auth tokens are stored in httpOnly cookies that persist across page reloads and work during SSR without localStorage errors.
Add Next.js middleware for session validation
Middleware runs before every route navigation, letting you check if the token is still valid and redirect to login before the page even renders. Without this, users see brief flashes of protected content before being bounced.
Add Next.js middleware for session validation
Middleware runs before every route navigation, letting you check if the token is still valid and redirect to login before the page even renders. Without this, users see brief flashes of protected content before being bounced.
Create a middleware.ts file in your project root that reads the access token cookie, checks its expiration, and either allows the request or redirects to login. Use the matcher config to apply only to protected routes.
// No middleware — pages load then check auth client-side// middleware.tsimport { NextResponse } from "next/server"import type { NextRequest } from "next/server"export function middleware(request: NextRequest) { const token = request.cookies.get("access_token")?.value if (!token) { return NextResponse.redirect(new URL("/login", request.url)) } try { const payload = JSON.parse(atob(token.split(".")[1])) const isExpired = payload.exp * 1000 < Date.now() if (isExpired) { const response = NextResponse.redirect(new URL("/api/auth/refresh", request.url)) return response } } catch { return NextResponse.redirect(new URL("/login", request.url)) } return NextResponse.next()}export const config = { matcher: ["/dashboard/:path*", "/settings/:path*", "/api/protected/:path*"],}Expected result: Navigating to any protected route automatically checks the token. Expired tokens trigger a refresh attempt. Invalid tokens redirect to login instantly without loading the protected page.
Implement automatic token refresh
Access tokens are short-lived by design. When one expires, the application should use the longer-lived refresh token to obtain a new access token without requiring the user to log in again.
Implement automatic token refresh
Access tokens are short-lived by design. When one expires, the application should use the longer-lived refresh token to obtain a new access token without requiring the user to log in again.
Create an API route that reads the refresh token from cookies, sends it to your auth provider to get a new access token, and sets the new access token as a cookie. Add this as a retry mechanism in your fetch wrapper.
// V0 generated — no refresh logicasync function fetchData() { const res = await fetch("/api/data") if (!res.ok) throw new Error("Failed to fetch") return res.json()}// app/api/auth/refresh/route.tsimport { cookies } from "next/headers"import { NextResponse } from "next/server"export async function POST() { const cookieStore = await cookies() const refreshToken = cookieStore.get("refresh_token")?.value if (!refreshToken) { return NextResponse.json({ error: "No refresh token" }, { status: 401 }) } const tokenResponse = await fetch(`${process.env.AUTH_URL}/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refreshToken }), }) if (!tokenResponse.ok) { cookieStore.delete("access_token") cookieStore.delete("refresh_token") return NextResponse.json({ error: "Refresh failed" }, { status: 401 }) } const { access_token, refresh_token } = await tokenResponse.json() cookieStore.set("access_token", access_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 3600, path: "/", }) return NextResponse.json({ success: true })}Expected result: When an access token expires, the refresh endpoint automatically issues a new one. The user stays logged in as long as their refresh token is valid.
Create an authenticated fetch wrapper with retry
Every API call from the client needs to handle 401 responses gracefully. Instead of duplicating retry logic everywhere, a single wrapper function attempts a refresh and retries the original request.
Create an authenticated fetch wrapper with retry
Every API call from the client needs to handle 401 responses gracefully. Instead of duplicating retry logic everywhere, a single wrapper function attempts a refresh and retries the original request.
Create a utility function that wraps fetch. On a 401 response, it calls the refresh endpoint, then retries the original request once. If the refresh also fails, it redirects to login.
1// lib/auth-fetch.ts2export async function authFetch(url: string, options?: RequestInit): Promise<Response> {3 let response = await fetch(url, options)45 if (response.status === 401) {6 const refreshRes = await fetch("/api/auth/refresh", { method: "POST" })78 if (refreshRes.ok) {9 response = await fetch(url, options)10 } else {11 window.location.href = "/login"12 }13 }1415 return response16}Expected result: API calls that receive a 401 automatically attempt a token refresh and retry. Users only see a login redirect when their refresh token has also expired.
Complete code example
1import { NextResponse } from "next/server"2import type { NextRequest } from "next/server"34function isTokenExpired(token: string): boolean {5 try {6 const payload = JSON.parse(atob(token.split(".")[1]))7 // JWT exp is in seconds, Date.now() is in milliseconds8 return payload.exp * 1000 < Date.now()9 } catch {10 return true11 }12}1314export async function middleware(request: NextRequest) {15 const accessToken = request.cookies.get("access_token")?.value16 const refreshToken = request.cookies.get("refresh_token")?.value17 const loginUrl = new URL("/login", request.url)1819 // No tokens at all — redirect to login20 if (!accessToken && !refreshToken) {21 return NextResponse.redirect(loginUrl)22 }2324 // Access token valid — allow request25 if (accessToken && !isTokenExpired(accessToken)) {26 return NextResponse.next()27 }2829 // Access token expired but refresh token exists — try refresh30 if (refreshToken) {31 try {32 const refreshRes = await fetch(33 new URL("/api/auth/refresh", request.url),34 {35 method: "POST",36 headers: { Cookie: `refresh_token=${refreshToken}` },37 }38 )3940 if (refreshRes.ok) {41 const response = NextResponse.next()42 const setCookie = refreshRes.headers.get("set-cookie")43 if (setCookie) {44 response.headers.set("set-cookie", setCookie)45 }46 return response47 }48 } catch {49 // Refresh failed — fall through to redirect50 }51 }5253 return NextResponse.redirect(loginUrl)54}5556export const config = {57 matcher: [58 "/dashboard/:path*",59 "/settings/:path*",60 "/api/protected/:path*",61 ],62}Best practices to prevent this
- Store auth tokens in httpOnly cookies, not localStorage, to prevent SSR errors and XSS vulnerabilities
- Always compare JWT exp (seconds) against Date.now() / 1000 or multiply exp by 1000 — mixing units is a common V0 bug
- Implement Next.js middleware for route-level session validation so protected pages never flash to unauthenticated users
- Use short-lived access tokens (15-60 minutes) with longer-lived refresh tokens (7-30 days) for security
- Create a centralized authFetch wrapper that handles 401 retries so refresh logic is not duplicated across components
- Set environment variables like AUTH_URL in the V0 Vars panel rather than hardcoding URLs in your code
- Add the secure and sameSite flags to all auth cookies in production to prevent CSRF attacks
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
My V0 Next.js app uses JWT authentication but breaks after the token expires. Users get 401 errors and the app tries to read localStorage during SSR. Help me implement httpOnly cookie storage, Next.js middleware for session validation, and automatic token refresh with a retry wrapper.
Frequently asked questions
Why does my V0 auth break after the user is idle for an hour?
JWT access tokens typically expire after 1 hour. V0 does not generate refresh token logic, so once the access token expires, all authenticated API calls fail with 401 errors. Implement a refresh endpoint and an authFetch wrapper to handle this automatically.
How do I fix the localStorage is not defined error in V0?
V0 accesses localStorage during server-side rendering where it does not exist. Switch to httpOnly cookies for token storage, which works on both server and client. If you must use localStorage, wrap the access in a useEffect hook that only runs on the client.
What are the best practices for token expiration?
Use short-lived access tokens (15-60 minutes) paired with longer-lived refresh tokens (7-30 days). Store both in httpOnly secure cookies. Check expiration in Next.js middleware before loading protected routes. Implement automatic refresh with a single retry on 401 responses.
How do I store auth-related environment variables in V0?
Open the Vars panel in the V0 editor and add your auth variables there (AUTH_URL, JWT_SECRET, OAUTH_CLIENT_ID). These are injected as environment variables at runtime. Never hardcode secrets in your source code.
Should I use NextAuth.js or build custom JWT handling in V0?
NextAuth.js v5 handles token rotation, session management, and provider integration out of the box. Use it unless you have specific requirements that demand custom JWT logic. V0 can scaffold NextAuth if you prompt it explicitly.
Can RapidDev help implement secure authentication in my V0 app?
Yes. Authentication is one of the most complex parts of a V0 project, involving token management, secure storage, middleware, and refresh flows. RapidDev engineers can implement production-grade auth with proper security practices and thorough testing.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your issue.
Book a free consultation