# Fixing token expiration issues in V0 auth flows

- Tool: V0
- Difficulty: Intermediate
- Fix time: 25-40 minutes
- Compatibility: V0 with Next.js App Router, NextAuth.js v5 or custom JWT, Supabase Auth or Firebase Auth
- Last updated: March 2026

## 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:

```
// V0 generated — breaks during SSR
const token = localStorage.getItem("auth_token")

const response = await fetch("/api/data", {
  headers: { Authorization: `Bearer ${token}` },
})
```

After:

```
// 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:

```
// No middleware — pages load then check auth client-side
```

After:

```
// 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:

```
// 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:

```
// 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.

After:

```
// lib/auth-fetch.ts
export async function authFetch(url: string, options?: RequestInit): Promise<Response> {
  let response = await fetch(url, options)

  if (response.status === 401) {
    const refreshRes = await fetch("/api/auth/refresh", { method: "POST" })

    if (refreshRes.ok) {
      response = await fetch(url, options)
    } else {
      window.location.href = "/login"
    }
  }

  return response
}
```

**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

File: `middleware.ts`

```typescript
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"

function isTokenExpired(token: string): boolean {
  try {
    const payload = JSON.parse(atob(token.split(".")[1]))
    // JWT exp is in seconds, Date.now() is in milliseconds
    return payload.exp * 1000 < Date.now()
  } catch {
    return true
  }
}

export async function middleware(request: NextRequest) {
  const accessToken = request.cookies.get("access_token")?.value
  const refreshToken = request.cookies.get("refresh_token")?.value
  const loginUrl = new URL("/login", request.url)

  // No tokens at all — redirect to login
  if (!accessToken && !refreshToken) {
    return NextResponse.redirect(loginUrl)
  }

  // Access token valid — allow request
  if (accessToken && !isTokenExpired(accessToken)) {
    return NextResponse.next()
  }

  // Access token expired but refresh token exists — try refresh
  if (refreshToken) {
    try {
      const refreshRes = await fetch(
        new URL("/api/auth/refresh", request.url),
        {
          method: "POST",
          headers: { Cookie: `refresh_token=${refreshToken}` },
        }
      )

      if (refreshRes.ok) {
        const response = NextResponse.next()
        const setCookie = refreshRes.headers.get("set-cookie")
        if (setCookie) {
          response.headers.set("set-cookie", setCookie)
        }
        return response
      }
    } catch {
      // Refresh failed — fall through to redirect
    }
  }

  return NextResponse.redirect(loginUrl)
}

export const config = {
  matcher: [
    "/dashboard/:path*",
    "/settings/:path*",
    "/api/protected/:path*",
  ],
}
```

## Best practices

- 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

## 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.

---

Source: https://www.rapidevelopers.com/v0-issues/fixing-token-expiration-issues-in-v0-auth-flows
© RapidDev — https://www.rapidevelopers.com/v0-issues/fixing-token-expiration-issues-in-v0-auth-flows
