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.
- Minimal boilerplate: Unlike Redux, Zustand does not require actions, reducers, or dispatch functions. You define a store with a single hook.
- Flexible persistence: The
persistmiddleware makes it trivial to store tokens or session IDs inlocalStorageorsessionStorage. - Selective subscriptions: Components only re-render when the slice of state they care about changes, which is ideal for auth state that rarely changes.
- Framework-agnostic core: The store can be accessed outside of React components, which is useful for API interceptors and route guards.
- TypeScript friendly: Zustand works seamlessly with TypeScript, allowing you to type your auth state and actions precisely.
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
- Never store tokens in plain JavaScript variables without persistence: If you need persistence, use
localStoragefor long-lived sessions orsessionStoragefor tab-scoped sessions. Be aware thatlocalStorageis vulnerable to XSS attacks. - Prefer HTTP-only cookies for sensitive tokens: If your backend supports it, store the refresh token in an HTTP-only, Secure, SameSite cookie. This prevents JavaScript from accessing it.
- Always validate the OAuth state parameter: The state parameter protects against CSRF attacks. Never skip this validation.
- Implement token expiration checks: Decode the JWT on the client to check expiration before making requests, and proactively refresh tokens that are about to expire.
- Clear all auth state on logout: Make sure your logout function clears both the Zustand store and any persisted storage. Also call the server logout endpoint to invalidate the session server-side.
Performance Best Practices
- Use selective subscriptions: Instead of subscribing to the entire store, select only the fields you need. This prevents unnecessary re-renders.
- Avoid storing derived state: Compute derived values like
isAdminin selectors rather than storing them in the store. - Use shallow equality for object selectors: When selecting multiple fields, use the
shallowcomparator to prevent re-renders when object references change but values do not.
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
- Keep auth logic in the store, not in components: Components should call store actions and read store state, not contain fetch logic directly.
- Use a single source of truth: Whether you choose JWT, sessions, or OAuth, unify your auth state in one store to avoid inconsistencies.
- Handle loading and error states explicitly: Always provide feedback to users during async operations. The
isLoadinganderrorfields in your store make this straightforward. - Test your auth flows: Write integration tests for login, logout, token refresh, and OAuth callback flows. Authentication bugs are among the most damaging in production.
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.