Skip to main content
RapidDev - Software Development Agency
v0-issues

Fixing token expiration issues in V0 auth flows

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.

Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate8 min read25-40 minutesV0 with Next.js App Router, NextAuth.js v5 or custom JWT, Supabase Auth or Firebase AuthMarch 2026RapidDev Engineering Team
TL;DR

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 defined

V0 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 expiration

V0 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

1

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.

Before
typescript
// V0 generated — breaks during SSR
const token = localStorage.getItem("auth_token")
const response = await fetch("/api/data", {
headers: { Authorization: `Bearer ${token}` },
})
After
typescript
// app/api/auth/login/route.ts
import { 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.

2

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.

Before
typescript
// No middleware — pages load then check auth client-side
After
typescript
// middleware.ts
import { 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.

3

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.

Before
typescript
// V0 generated — no refresh logic
async function fetchData() {
const res = await fetch("/api/data")
if (!res.ok) throw new Error("Failed to fetch")
return res.json()
}
After
typescript
// app/api/auth/refresh/route.ts
import { 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.

4

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.

typescript
1// lib/auth-fetch.ts
2export async function authFetch(url: string, options?: RequestInit): Promise<Response> {
3 let response = await fetch(url, options)
4
5 if (response.status === 401) {
6 const refreshRes = await fetch("/api/auth/refresh", { method: "POST" })
7
8 if (refreshRes.ok) {
9 response = await fetch(url, options)
10 } else {
11 window.location.href = "/login"
12 }
13 }
14
15 return response
16}

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

middleware.ts
1import { NextResponse } from "next/server"
2import type { NextRequest } from "next/server"
3
4function 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 milliseconds
8 return payload.exp * 1000 < Date.now()
9 } catch {
10 return true
11 }
12}
13
14export async function middleware(request: NextRequest) {
15 const accessToken = request.cookies.get("access_token")?.value
16 const refreshToken = request.cookies.get("refresh_token")?.value
17 const loginUrl = new URL("/login", request.url)
18
19 // No tokens at all — redirect to login
20 if (!accessToken && !refreshToken) {
21 return NextResponse.redirect(loginUrl)
22 }
23
24 // Access token valid — allow request
25 if (accessToken && !isTokenExpired(accessToken)) {
26 return NextResponse.next()
27 }
28
29 // Access token expired but refresh token exists — try refresh
30 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 )
39
40 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 response
47 }
48 } catch {
49 // Refresh failed — fall through to redirect
50 }
51 }
52
53 return NextResponse.redirect(loginUrl)
54}
55
56export 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.

ChatGPT Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your issue.

Book a free consultation

Need help with your Lovable project?

Our experts have built 600+ apps and can solve your issue fast. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.