# How to Handle JWT Expiration in Supabase

- Tool: Supabase
- Difficulty: Intermediate
- Time required: 15-20 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+
- Last updated: March 2026

## TL;DR

Supabase JWTs expire after 3600 seconds (1 hour) by default. The Supabase client automatically refreshes tokens before they expire using the refresh token. To handle edge cases, listen for the TOKEN_REFRESHED event via onAuthStateChange, configure the JWT expiry time in the Dashboard under Settings > Auth, and always use getUser() instead of getSession() for server-side verification since getSession() reads from local cache without verifying expiration.

## Handling JWT Expiration in Supabase Auth

Every authenticated Supabase request includes a JWT (JSON Web Token) that contains the user's identity and role. These tokens expire after a configurable period, and the Supabase client handles renewal automatically in most cases. But edge cases exist — background tabs, long-running operations, and server-side verification all require special attention. This tutorial explains how JWT expiration works in Supabase, how to configure it, and how to handle failures gracefully.

## Before you start

- A Supabase project with Auth enabled
- Basic understanding of JWTs and authentication tokens
- @supabase/supabase-js v2+ installed in your project
- An existing login flow in your application

## Step-by-step guide

### 1. Understand how Supabase JWT auto-refresh works

When a user signs in, Supabase issues two tokens: an access token (JWT) and a refresh token. The access token is short-lived (default 3600 seconds) and is sent with every API request. The refresh token is long-lived and is used to get a new access token when the current one expires. The Supabase client automatically refreshes the access token before it expires, typically 60 seconds before expiry. This happens transparently — you do not need to write any code for the standard case.

```
// The Supabase client handles auto-refresh internally.
// When you create the client, auto-refresh is enabled by default:
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  {
    auth: {
      autoRefreshToken: true,  // default: true
      persistSession: true,    // default: true
    },
  }
)
```

**Expected result:** You understand that the Supabase client refreshes tokens automatically before they expire.

### 2. Configure JWT expiry time in the Dashboard

The default JWT expiry is 3600 seconds (1 hour). You can adjust this in the Supabase Dashboard. Go to Settings > Auth > Auth Settings and find the JWT Expiry field. Shorter expiry times (e.g., 900 seconds / 15 minutes) are more secure but require more frequent token refreshes. Longer times (e.g., 86400 seconds / 24 hours) reduce refresh requests but increase the window of vulnerability if a token is leaked. For most applications, the default of 3600 seconds is a good balance.

```
# JWT expiry is configured in the Dashboard:
# Settings > Auth > Auth Settings > JWT Expiry
#
# Common values:
# 900    = 15 minutes (high security)
# 3600   = 1 hour (default, recommended)
# 86400  = 24 hours (convenience, lower security)
#
# You can also set it via environment variable:
# GOTRUE_JWT_EXP=3600
```

**Expected result:** The JWT expiry time is configured in the Dashboard to match your security requirements.

### 3. Listen for token refresh events with onAuthStateChange

The onAuthStateChange listener fires whenever the auth state changes, including when tokens are refreshed. Listen for the TOKEN_REFRESHED event to confirm that auto-refresh is working and to handle edge cases. If a refresh fails (for example, because the refresh token expired or the user revoked their session), you will receive a SIGNED_OUT event instead. Use this to redirect the user to the login page or show a re-authentication prompt.

```
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

const { data: { subscription } } = supabase.auth.onAuthStateChange(
  (event, session) => {
    switch (event) {
      case 'TOKEN_REFRESHED':
        console.log('Token refreshed successfully')
        // Optionally update any stored session data
        break
      case 'SIGNED_OUT':
        console.log('User signed out or token refresh failed')
        // Redirect to login page
        window.location.href = '/login'
        break
      case 'SIGNED_IN':
        console.log('User signed in', session?.user.email)
        break
    }
  }
)

// Cleanup when component unmounts
// subscription.unsubscribe()
```

**Expected result:** Your application listens for TOKEN_REFRESHED and SIGNED_OUT events and responds appropriately.

### 4. Handle expired tokens in API requests

In rare cases, a token can expire between the auto-refresh check and your API request. This results in a 401 error from the Supabase API. Handle this by catching the error, manually refreshing the session, and retrying the request. This is especially important for long-running operations where the token might expire mid-process. The Supabase client's built-in retry logic handles most cases, but explicit handling adds robustness.

```
async function fetchDataWithRetry(tableName: string) {
  const { data, error } = await supabase
    .from(tableName)
    .select('*')

  if (error && error.message.includes('JWT expired')) {
    // Token expired between refresh and request
    const { error: refreshError } = await supabase.auth.refreshSession()

    if (refreshError) {
      // Refresh token also expired — user must re-authenticate
      console.error('Session expired. Please log in again.')
      window.location.href = '/login'
      return null
    }

    // Retry the original request with the new token
    const { data: retryData, error: retryError } = await supabase
      .from(tableName)
      .select('*')

    if (retryError) throw retryError
    return retryData
  }

  if (error) throw error
  return data
}
```

**Expected result:** Your application gracefully handles JWT expiration by refreshing the token and retrying failed requests.

### 5. Use getUser() instead of getSession() for server-side verification

On the server side, never trust getSession() to verify whether a token is valid. getSession() reads the JWT from cookies or local storage without making an API call — it does not check whether the token has expired or been revoked. Always use getUser(), which sends the token to the Supabase Auth server for verification. If the token is expired and cannot be refreshed, getUser() returns an error instead of stale user data.

```
// WRONG: getSession() does not verify the token
// It may return an expired session that looks valid
const { data: { session } } = await supabase.auth.getSession()
// session.user may exist even if the JWT is expired!

// CORRECT: getUser() verifies the token with the server
const { data: { user }, error } = await supabase.auth.getUser()

if (error || !user) {
  // Token is expired or invalid — redirect to login
  redirect('/login')
}

// Safe to proceed with authenticated operations
const { data } = await supabase
  .from('profiles')
  .select('*')
  .eq('id', user.id)
  .single()
```

**Expected result:** Server-side code uses getUser() for reliable token verification that catches expired JWTs.

## Complete code example

File: `supabase-auth-session.ts`

```typescript
// Complete JWT expiration handling for Supabase
import { createClient, AuthChangeEvent, Session } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  {
    auth: {
      autoRefreshToken: true,
      persistSession: true,
    },
  }
)

// Listen for auth state changes including token refresh
function setupAuthListener() {
  const { data: { subscription } } = supabase.auth.onAuthStateChange(
    (event: AuthChangeEvent, session: Session | null) => {
      switch (event) {
        case 'TOKEN_REFRESHED':
          console.log('JWT auto-refreshed at', new Date().toISOString())
          break
        case 'SIGNED_OUT':
          console.log('Session ended — redirecting to login')
          window.location.href = '/login'
          break
        case 'SIGNED_IN':
          console.log('User signed in:', session?.user?.email)
          break
      }
    }
  )
  return subscription
}

// Fetch data with automatic retry on JWT expiration
async function fetchWithAuth<T>(
  queryFn: () => Promise<{ data: T | null; error: any }>
): Promise<T | null> {
  const { data, error } = await queryFn()

  if (error?.message?.includes('JWT expired')) {
    const { error: refreshError } = await supabase.auth.refreshSession()
    if (refreshError) {
      window.location.href = '/login'
      return null
    }
    const { data: retryData, error: retryError } = await queryFn()
    if (retryError) throw retryError
    return retryData
  }

  if (error) throw error
  return data
}

// Server-side: Always use getUser(), never getSession()
async function getAuthenticatedUser() {
  const { data: { user }, error } = await supabase.auth.getUser()
  if (error || !user) return null
  return user
}

export { supabase, setupAuthListener, fetchWithAuth, getAuthenticatedUser }
```

## Common mistakes

- **Using getSession() on the server to check if a user is authenticated, which returns stale data without verifying the JWT** — undefined Fix: Always use getUser() for server-side auth checks. It sends the JWT to the Supabase Auth server for verification and catches expired tokens.
- **Setting JWT expiry too short (under 300 seconds), causing excessive token refresh requests** — undefined Fix: Keep JWT expiry at 3600 seconds (1 hour) or above for most applications. The Supabase client needs time between refresh cycles.
- **Disabling autoRefreshToken in client-side code without implementing manual refresh logic** — undefined Fix: Leave autoRefreshToken set to true (the default). Only disable it in server-side admin clients where session persistence is not needed.
- **Not handling the SIGNED_OUT event when token refresh fails, leaving the user in a broken state** — undefined Fix: Listen for SIGNED_OUT in onAuthStateChange and redirect the user to the login page. This covers cases where the refresh token itself has expired.

## Best practices

- Keep the default JWT expiry of 3600 seconds unless you have specific security requirements
- Always use getUser() instead of getSession() for server-side authentication checks
- Listen for TOKEN_REFRESHED and SIGNED_OUT events with onAuthStateChange
- Implement retry logic for API requests that fail with JWT expired errors
- Clean up onAuthStateChange subscriptions when components unmount to prevent memory leaks
- Never set autoRefreshToken to false in client-side code
- Use @supabase/ssr for SSR frameworks to get cookie-based session management that handles token refresh correctly

## Frequently asked questions

### What is the default JWT expiry time in Supabase?

The default JWT expiry is 3600 seconds (1 hour). You can change this in the Supabase Dashboard under Settings > Auth > Auth Settings > JWT Expiry.

### Does the Supabase client automatically refresh expired tokens?

Yes. The client refreshes the access token automatically about 60 seconds before it expires, using the refresh token. This happens transparently as long as autoRefreshToken is true (the default).

### What happens if the refresh token expires?

If the refresh token expires, the auto-refresh fails and the user is effectively signed out. Your onAuthStateChange listener will receive a SIGNED_OUT event. The user must sign in again to get new tokens.

### Should I use getSession() or getUser() to check if a token is valid?

Use getUser() for any security-sensitive check, especially on the server. getSession() reads from local storage without verifying the token and may return an expired session that appears valid. getUser() sends the token to the Supabase Auth server for verification.

### Can I set different JWT expiry times for different users?

No. JWT expiry is a project-wide setting. All users in the same Supabase project share the same expiry time. If you need different session durations, implement custom logic in your application.

### Why does my user get logged out when switching browser tabs?

Browser throttling can pause JavaScript timers in background tabs, preventing the auto-refresh from running. When the tab becomes active again, the token may already be expired. The client will attempt a refresh, but if it fails, the user is signed out. Listening for visibilitychange events and calling refreshSession() when the tab becomes visible can help.

### Can RapidDev help implement robust session management with Supabase?

Yes. RapidDev can configure JWT expiry settings, implement token refresh retry logic, set up proper onAuthStateChange listeners, and ensure your server-side auth checks use getUser() correctly across your entire application.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-handle-jwt-expiration-in-supabase
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-handle-jwt-expiration-in-supabase
