← Back to DevBytes

Zustand Authentication: JWT, Sessions, and OAuth Integration

Introduction to Zustand Authentication

Authentication is one of the most critical concerns in modern web applications. Whether you are building a small dashboard or a large-scale SaaS platform, managing the authenticated user state cleanly and predictably is essential. Zustand, a lightweight state management library for React, has become a popular choice for handling authentication state because of its simplicity, minimal boilerplate, and powerful middleware support.

In this tutorial, we will explore how to implement authentication in a React application using Zustand. We will cover three of the most common authentication strategies: JWT (JSON Web Tokens), session-based authentication, and OAuth integration. By the end, you will have a solid understanding of how to structure your auth store, persist credentials safely, and integrate with external providers.

Why Use Zustand for Authentication?

Before diving into implementation, it is worth understanding why Zustand is a good fit for authentication state management.

Setting Up the Project

Let us start by creating a new React project and installing the necessary dependencies. We will assume you are using Vite, but the same steps apply to Create React App or Next.js.

npm create vite@latest zustand-auth-app -- --template react-ts
cd zustand-auth-app
npm install
npm install zustand axios

We install axios because it makes HTTP requests and interceptors easier to manage, which is important for attaching tokens to API calls and handling token refresh.

JWT Authentication with Zustand

JWT authentication is the most common stateless authentication mechanism. The server issues a signed token after the user logs in, and the client includes this token in the Authorization header of subsequent requests. Let us build a complete JWT auth store.

Defining the Auth Store

Create a file at src/store/authStore.ts. This store will hold the user object, the access token, and the refresh token, along with login, logout, and token refresh actions.

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user' | 'manager';
}

interface AuthState {
  user: User | null;
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  refreshAccessToken: () => Promise<void>;
  clearError: () => void;
}

const API_BASE_URL = 'https://api.example.com';

export const useAuthStore = create<AuthState>()(
  persist(
    (set, get) => ({
      user: null,
      accessToken: null,
      refreshToken: null,
      isAuthenticated: false,
      isLoading: false,
      error: null,

      login: async (email: string, password: string) => {
        set({ isLoading: true, error: null });
        try {
          const response = await fetch(`${API_BASE_URL}/auth/login`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, password }),
          });

          if (!response.ok) {
            const errorData = await response.json();
            throw new Error(errorData.message || 'Login failed');
          }

          const data = await response.json();
          set({
            user: data.user,
            accessToken: data.accessToken,
            refreshToken: data.refreshToken,
            isAuthenticated: true,
            isLoading: false,
            error: null,
          });
        } catch (err) {
          set({
            isLoading: false,
            error: err instanceof Error ? err.message : 'Unknown error',
          });
          throw err;
        }
      },

      logout: () => {
        set({
          user: null,
          accessToken: null,
          refreshToken: null,
          isAuthenticated: false,
          error: null,
        });
      },

      refreshAccessToken: async () => {
        const currentRefreshToken = get().refreshToken;
        if (!currentRefreshToken) {
          get().logout();
          return;
        }

        try {
          const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ refreshToken: currentRefreshToken }),
          });

          if (!response.ok) {
            get().logout();
            return;
          }

          const data = await response.json();
          set({ accessToken: data.accessToken });
        } catch {
          get().logout();
        }
      },

      clearError: () => set({ error: null }),
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({
        accessToken: state.accessToken,
        refreshToken: state.refreshToken,
        user: state.user,
        isAuthenticated: state.isAuthenticated,
      }),
    }
  )
);

Notice the partialize option in the persist middleware. This ensures we only persist the essential fields and not transient state like isLoading or error. This is a best practice because you do not want a stale loading state to be restored on page refresh.

Creating an Axios Interceptor

One of the most powerful patterns with Zustand is accessing the store outside of React components. We can use this to create an Axios instance that automatically attaches the JWT to every request and handles token refresh on 401 responses.

// src/lib/api.ts
import axios from 'axios';
import { useAuthStore } from '../store/authStore';

const API_BASE_URL = 'https://api.example.com';

export const api = axios.create({
  baseURL: API_BASE_URL,
  headers: { 'Content-Type': 'application/json' },
});

// Request interceptor: attach access token
api.interceptors.request.use(
  (config) => {
    const token = useAuthStore.getState().accessToken;
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor: handle token refresh on 401
let isRefreshing = false;
let failedQueue: Array<{ resolve: Function; reject: Function }> = [];

const processQueue = (error: any, token: string | null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  failedQueue = [];
};

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            return api(originalRequest);
          })
          .catch((err) => Promise.reject(err));
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        await useAuthStore.getState().refreshAccessToken();
        const newToken = useAuthStore.getState().accessToken;
        processQueue(null, newToken);
        originalRequest.headers.Authorization = `Bearer ${newToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        useAuthStore.getState().logout();
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

This interceptor pattern handles the common scenario where multiple requests fail simultaneously with a 401. Instead of triggering multiple refresh calls, we queue the failed requests and replay them once the new token is obtained.

Building a Login Component

Now let us create a login form that uses our auth store.

// src/components/LoginForm.tsx
import { useState } from 'react';
import { useAuthStore } from '../store/authStore';

export function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const { login, isLoading, error, clearError } = useAuthStore();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await login(email, password);
    } catch {
      // Error is already stored in the auth store
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Sign In</h2>
      {error && (
        <div role="alert">
          {error}
          <button onClick={clearError}>Dismiss</button>
        </div>
      )}
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Signing in...' : 'Sign In'}
      </button>
    </form>
  );
}

Protecting Routes

A protected route component ensures that only authenticated users can access certain pages. We can build this using Zustand's state directly.

// src/components/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';

interface ProtectedRouteProps {
  children: React.ReactNode;
  roles?: string[];
}

export function ProtectedRoute({ children, roles }: ProtectedRouteProps) {
  const location = useLocation();
  const { isAuthenticated, user } = useAuthStore();

  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  if (roles && user && !roles.includes(user.role)) {
    return <Navigate to="/unauthorized" replace />;
  }

  return <>{children}</>;
}

You can use this component in your router configuration like this:

<Routes>
  <Route path="/login" element={<LoginForm />} />
  <Route
    path="/dashboard"
    element={
      <ProtectedRoute>
        <Dashboard />
      </ProtectedRoute>
    }
  />
  <Route
    path="/admin"
    element={
      <ProtectedRoute roles={['admin']}>
        <AdminPanel />
      </ProtectedRoute>
    }
  />
</Routes>

Session-Based Authentication

Session-based authentication is an alternative to JWT where the server stores session data and issues a session ID, typically via an HTTP-only cookie. The client does not need to manage tokens at all; the browser automatically sends the cookie with each request. This approach is considered more secure against XSS attacks because the session ID is not accessible via JavaScript.

Session Auth Store

The Zustand store for session-based auth is simpler because we do not store tokens. We only need to track whether the user is authenticated and who the user is.

// src/store/sessionAuthStore.ts
import { create } from 'zustand';

interface SessionUser {
  id: string;
  email: string;
  name: string;
}

interface SessionAuthState {
  user: SessionUser | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  fetchSession: () => Promise<void>;
  clearError: () => void;
}

const API_BASE_URL = 'https://api.example.com';

export const useSessionAuthStore = create<SessionAuthState>()((set) => ({
  user: null,
  isAuthenticated: false,
  isLoading: false,
  error: null,

  login: async (email, password) => {
    set({ isLoading: true, error: null });
    try {
      const response = await fetch(`${API_BASE_URL}/auth/session/login`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include', // Important: send and receive cookies
        body: JSON.stringify({ email, password }),
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.message || 'Login failed');
      }

      const data = await response.json();
      set({
        user: data.user,
        isAuthenticated: true,
        isLoading: false,
      });
    } catch (err) {
      set({
        isLoading: false,
        error: err instanceof Error ? err.message : 'Unknown error',
      });
      throw err;
    }
  },

  logout: async () => {
    try {
      await fetch(`${API_BASE_URL}/auth/session/logout`, {
        method: 'POST',
        credentials: 'include',
      });
    } catch {
      // Even if the server call fails, clear local state
    } finally {
      set({ user: null, isAuthenticated: false, error: null });
    }
  },

  fetchSession: async () => {
    set({ isLoading: true });
    try {
      const response = await fetch(`${API_BASE_URL}/auth/session/me`, {
        credentials: 'include',
      });

      if (response.ok) {
        const data = await response.json();
        set({ user: data.user, isAuthenticated: true, isLoading: false });
      } else {
        set({ user: null, isAuthenticated: false, isLoading: false });
      }
    } catch {
      set({ user: null, isAuthenticated: false, isLoading: false });
    }
  },

  clearError: () => set({ error: null }),
}));

The key difference here is the credentials: 'include' option on every fetch call. This tells the browser to include cookies in cross-origin requests. Note that we do not use the persist middleware here because the session is managed entirely by the server via cookies. On application load, we call fetchSession to check if the user has an active session.

Initializing Session on App Load

// src/App.tsx
import { useEffect } from 'react';
import { useSessionAuthStore } from './store/sessionAuthStore';

function App() {
  const { fetchSession, isLoading, isAuthenticated } = useSessionAuthStore();

  useEffect(() => {
    fetchSession();
  }, [fetchSession]);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      {isAuthenticated ? <Dashboard /> : <LoginForm />}
    </div>
  );
}

OAuth Integration

OAuth allows users to authenticate using third-party providers like Google, GitHub, or Microsoft. The OAuth 2.0 Authorization Code flow is the most common pattern for web applications. The flow involves redirecting the user to the provider, receiving an authorization code, and exchanging it for tokens on your backend.

OAuth Auth Store

Let us extend our auth store to handle OAuth flows. We will support multiple providers and handle the callback redirect.

// src/store/oauthStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface OAuthUser {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  provider: 'google' | 'github' | 'microsoft';
}

interface OAuthState {
  user: OAuthUser | null;
  accessToken: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  error: string | null;
  initiateOAuth: (provider: 'google' | 'github' | 'microsoft') => void;
  handleOAuthCallback: (code: string, state: string) => Promise<void>;
  logout: () => void;
  clearError: () => void;
}

const API_BASE_URL = 'https://api.example.com';
const OAUTH_CONFIG = {
  google: {
    clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
    redirectUri: `${window.location.origin}/auth/callback/google`,
    authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
    scope: 'openid email profile',
  },
  github: {
    clientId: import.meta.env.VITE_GITHUB_CLIENT_ID,
    redirectUri: `${window.location.origin}/auth/callback/github`,
    authUrl: 'https://github.com/login/oauth/authorize',
    scope: 'user:email',
  },
  microsoft: {
    clientId: import.meta.env.VITE_MICROSOFT_CLIENT_ID,
    redirectUri: `${window.location.origin}/auth/callback/microsoft`,
    authUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
    scope: 'openid email profile',
  },
};

function generateState(): string {
  return crypto.randomUUID();
}

export const useOAuthStore = create<OAuthState>()(
  persist(
    (set) => ({
      user: null,
      accessToken: null,
      isAuthenticated: false,
      isLoading: false,
      error: null,

      initiateOAuth: (provider) => {
        const config = OAUTH_CONFIG[provider];
        if (!config) {
          set({ error: `Unknown provider: ${provider}` });
          return;
        }

        const state = generateState();
        sessionStorage.setItem('oauth_state', state);
        sessionStorage.setItem('oauth_provider', provider);

        const params = new URLSearchParams({
          client_id: config.clientId,
          redirect_uri: config.redirectUri,
          response_type: 'code',
          scope: config.scope,
          state,
        });

        window.location.href = `${config.authUrl}?${params.toString()}`;
      },

      handleOAuthCallback: async (code, state) => {
        const savedState = sessionStorage.getItem('oauth_state');
        const provider = sessionStorage.getItem('oauth_provider') as
          | 'google'
          | 'github'
          | 'microsoft'
          | null;

        if (!savedState || savedState !== state) {
          set({ error: 'Invalid OAuth state. Possible CSRF attack.' });
          return;
        }

        if (!provider) {
          set({ error: 'No OAuth provider found in session.' });
          return;
        }

        set({ isLoading: true, error: null });

        try {
          const response = await fetch(
            `${API_BASE_URL}/auth/oauth/${provider}/callback`,
            {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ code, provider }),
            }
          );

          if (!response.ok) {
            const errorData = await response.json();
            throw new Error(errorData.message || 'OAuth authentication failed');
          }

          const data = await response.json();
          set({
            user: data.user,
            accessToken: data.accessToken,
            isAuthenticated: true,
            isLoading: false,
          });

          // Clean up session storage
          sessionStorage.removeItem('oauth_state');
          sessionStorage.removeItem('oauth_provider');
        } catch (err) {
          set({
            isLoading: false,
            error: err instanceof Error ? err.message : 'OAuth failed',
          });
          throw err;
        }
      },

      logout: () => {
        set({
          user: null,
          accessToken: null,
          isAuthenticated: false,
          error: null,
        });
      },

      clearError: () => set({ error: null }),
    }),
    {
      name: 'oauth-storage',
      partialize: (state) => ({
        user: state.user,
        accessToken: state.accessToken,
        isAuthenticated: state.isAuthenticated,
      }),
    }
  )
);

OAuth Login Buttons Component

// src/components/OAuthButtons.tsx
import { useOAuthStore } from '../store/oauthStore';

export function OAuthButtons() {
  const initiateOAuth = useOAuthStore((state) => state.initiateOAuth);

  return (
    <div>
      <p>Or continue with:</p>
      <div>
        <button onClick={() => initiateOAuth('google')}>
          Sign in with Google
        </button>
        <button onClick={() => initiateOAuth('github')}>
          Sign in with GitHub
        </button>
        <button onClick={() => initiateOAuth('microsoft')}>
          Sign in with Microsoft
        </button>
      </div>
    </div>
  );
}

OAuth Callback Handler

After the user authenticates with the provider, they are redirected back to your application with an authorization code and state parameter. You need a callback component to handle this.

// src/components/OAuthCallback.tsx
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useOAuthStore } from '../store/oauthStore';

export function OAuthCallback() {
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  const { handleOAuthCallback, error, isLoading } = useOAuthStore();

  useEffect(() => {
    const code = searchParams.get('code');
    const state = searchParams.get('state');
    const errorParam = searchParams.get('error');

    if (errorParam) {
      navigate('/login', { state: { error: errorParam } });
      return;
    }

    if (!code || !state) {
      navigate('/login');
      return;
    }

    handleOAuthCallback(code, state)
      .then(() => {
        navigate('/dashboard');
      })
      .catch(() => {
        navigate('/login');
      });
  }, [searchParams, navigate, handleOAuthCallback]);

  if (isLoading) {
    return <div>Completing authentication...</div>;
  }

  if (error) {
    return <div>Authentication error: {error}</div>;
  }

  return <div>Redirecting...</div>;
}

Do not forget to add the callback route to your router:

<Route path="/auth/callback/:provider" element={<OAuthCallback />} />

Combining JWT and OAuth

In real-world applications, you often want to support both traditional email/password login and OAuth providers. The cleanest approach is to unify them into a single auth store. After an OAuth callback, your backend can issue the same JWT tokens it issues for regular logins, so the client-side state management remains identical.

// src/store/unifiedAuthStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  role: string;
  authProvider: 'local' | 'google' | 'github' | 'microsoft';
}

interface UnifiedAuthState {
  user: User | null;
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  error: string | null;

  loginWithCredentials: (email: string, password: string) => Promise<void>;
  loginWithOAuthCode: (code: string, provider: string, state: string) => Promise<void>;
  initiateOAuth: (provider: string) => void;
  refreshAccessToken: () => Promise<void>;
  logout: () => void;
  clearError: () => void;
}

const API_BASE_URL = 'https://api.example.com';

export const useUnifiedAuthStore = create<UnifiedAuthState>()(
  persist(
    (set, get) => ({
      user: null,
      accessToken: null,
      refreshToken: null,
      isAuthenticated: false,
      isLoading: false,
      error: null,

      loginWithCredentials: async (email, password) => {
        set({ isLoading: true, error: null });
        try {
          const response = await fetch(`${API_BASE_URL}/auth/login`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, password }),
          });
          if (!response.ok) throw new Error('Login failed');
          const data = await response.json();
          set({
            user: { ...data.user, authProvider: 'local' },
            accessToken: data.accessToken,
            refreshToken: data.refreshToken,
            isAuthenticated: true,
            isLoading: false,
          });
        } catch (err) {
          set({
            isLoading: false,
            error: err instanceof Error ? err.message : 'Login failed',
          });
          throw err;
        }
      },

      initiateOAuth: (provider) => {
        const state = crypto.randomUUID();
        sessionStorage.setItem('oauth_state', state);
        sessionStorage.setItem('oauth_provider', provider);
        const redirectUri = encodeURIComponent(
          `${window.location.origin}/auth/callback/${provider}`
        );
        const authUrls: Record<string, string> = {
          google: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${
            import.meta.env.VITE_GOOGLE_CLIENT_ID
          }&redirect_uri=${redirectUri}&response_type=code&scope=openid email profile&state=${state}`,
          github: `https://github.com/login/oauth/authorize?client_id=${
            import.meta.env.VITE_GITHUB_CLIENT_ID
          }&redirect_uri=${redirectUri}&scope=user:email&state=${state}`,
        };
        window.location.href = authUrls[provider] || '/login';
      },

      loginWithOAuthCode: async (code, provider, state) => {
        const savedState = sessionStorage.getItem('oauth_state');
        if (savedState !== state) {
          set({ error: 'Invalid state parameter' });
          throw new Error('Invalid state');
        }

        set({ isLoading: true, error: null });
        try {
          const response = await fetch(
            `${API_BASE_URL}/auth/oauth/${provider}/callback`,
            {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ code }),
            }
          );
          if (!response.ok) throw new Error('OAuth login failed');
          const data = await response.json();
          set({
            user: { ...data.user, authProvider: provider },
            accessToken: data.accessToken,
            refreshToken: data.refreshToken,
            isAuthenticated: true,
            isLoading: false,
          });
          sessionStorage.removeItem('oauth_state');
          sessionStorage.removeItem('oauth_provider');
        } catch (err) {
          set({
            isLoading: false,
            error: err instanceof Error ? err.message : 'OAuth failed',
          });
          throw err;
        }
      },

      refreshAccessToken: async () => {
        const refreshToken = get().refreshToken;
        if (!refreshToken) {
          get().logout();
          return;
        }
        try {
          const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ refreshToken }),
          });
          if (!response.ok) {
            get().logout();
            return;
          }
          const data = await response.json();
          set({ accessToken: data.accessToken });
        } catch {
          get().logout();
        }
      },

      logout: () => {
        set({
          user: null,
          accessToken: null,
          refreshToken: null,
          isAuthenticated: false,
          error: null,
        });
      },

      clearError: () => set({ error: null }),
    }),
    {
      name: 'unified-auth-storage',
      partialize: (state) => ({
        user: state.user,
        accessToken: state.accessToken,
        refreshToken: state.refreshToken,
        isAuthenticated: state.isAuthenticated,
      }),
    }
  )
);

Best Practices

Now that we have covered the implementation details, let us review some best practices that will make your authentication implementation more robust and secure.

Security Best Practices

Performance Best Practices

Here is an example of using the shallow comparator:

import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore';

function UserProfile() {
  const { user, isAuthenticated } = useAuthStore(
    useShallow((state) => ({
      user: state.user,
      isAuthenticated: state.isAuthenticated,
    }))
  );

  if (!isAuthenticated || !user) return null;

  return <div>Welcome, {user.name}!</div>;
}

Architecture Best Practices

Token Expiration Proactive Refresh

Instead of waiting for a 401 response, you can proactively refresh tokens before they expire. Here is a utility function that decodes a JWT and checks its expiration:

// src/utils/tokenUtils.ts
export function decodeJWT(token: string): any | null {
  try {
    const base64Url = token.split('.')[1];
    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
    const jsonPayload = decodeURIComponent(
      atob(base64)
        .split('')
        .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
        .join('')
    );
    return JSON.parse(jsonPayload);
  } catch {
    return null;
  }
}

export function isTokenExpired(token: string, bufferSeconds = 30): boolean {
  const decoded = decodeJWT(token);
  if (!decoded || !decoded.exp) return true;
  const now = Math.floor(Date.now() / 1000);
  return decoded.exp - now < bufferSeconds;
}

You can then use this in a React hook that periodically checks and refreshes the token:

// src/hooks/useTokenRefresh.ts
import { useEffect } from 'react';
import { useAuthStore } from '../store/authStore';
import { isTokenExpired } from '../utils/tokenUtils';

export function useTokenRefresh() {
  const { accessToken, refreshAccessToken, isAuthenticated } = useAuthStore();

  useEffect(() => {
    if (!isAuthenticated || !accessToken) return;

    const checkAndRefresh = () => {
      if (isTokenExpired(accessToken, 60)) {
        refreshAccessToken();
      }
    };

    // Check immediately and then every minute
    checkAndRefresh();
    const interval = setInterval(checkAndRefresh, 60000);

    return () => clearInterval(interval);
  }, [accessToken, isAuthenticated, refreshAccessToken]);
}

Call this hook once at the top level of your authenticated application, for example in your main layout component.

Conclusion

Zustand provides an elegant and powerful way to manage authentication state in React applications. Its minimal API, combined with middleware like persist, makes it straightforward to implement JWT-based authentication, session-based authentication, and OAuth integration. By following the patterns and best practices outlined in this tutorial, you can build a secure, performant, and maintainable authentication system. Remember that authentication is a security-critical feature, so always validate state parameters, handle token expiration gracefully, clear state on logout, and prefer HTTP-only cookies for the most sensitive credentials when your architecture allows it. With a well-structured Zustand auth store, your application will have a clean separation of concerns, predictable state updates, and a foundation that scales as your authentication requirements grow.

— Ad —

Google AdSense will appear here after approval

← Back to all articles