← Back to DevBytes

Jotai Authentication: JWT, Sessions, and OAuth Integration

Introduction to Jotai Authentication

Authentication state management in modern React applications presents unique challenges: token persistence, session lifecycle, refresh flows, and third-party OAuth integration. Jotai, a lightweight atomic state management library, offers a remarkably clean approach to handling authentication state without the boilerplate overhead of larger state management solutions. This tutorial walks you through building a complete authentication system using Jotai, covering JWT token handling, session management, and OAuth provider integration.

What is Jotai and Why Use It for Authentication?

Jotai is a primitive and flexible state management library for React built on atomic units of state called "atoms." Unlike Redux's single store or Zustand's store slices, Jotai atoms can be composed, derived, and shared independently. For authentication, this means you can create dedicated atoms for tokens, user data, and session status that interact seamlessly without unnecessary re-renders. Key advantages include:

Setting Up the Project Foundation

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Begin by installing Jotai and any supplementary libraries you'll need for authentication utilities:

npm install jotai jwt-decode
# Optional but recommended for secure storage
npm install js-cookie

Create a dedicated auth atoms file to centralize all authentication-related state. This keeps your authentication logic cohesive and easily auditable:

// atoms/auth.ts
import { atom } from 'jotai';
import { atomWithStorage, atomWithRefresh } from 'jotai/utils';
import Cookies from 'js-cookie';

// Type definitions for strong typing
export interface AuthTokens {
  accessToken: string | null;
  refreshToken: string | null;
}

export interface UserProfile {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  roles: string[];
}

export type AuthStatus = 'idle' | 'authenticated' | 'expired' | 'loading';

JWT Token Integration with Jotai

JSON Web Tokens (JWTs) are the most common authentication mechanism in modern web applications. The core challenge is storing tokens securely, decoding claims client-side, and handling token refresh seamlessly. Jotai's atomWithStorage primitive provides persistence with localStorage or sessionStorage, while atomWithRefresh enables async refresh logic.

Creating Persistent Token Atoms

The foundation of JWT authentication is secure token storage. Use atomWithStorage to persist tokens across page reloads while keeping them reactive:

// atoms/auth.ts (continued)
import { atomWithStorage } from 'jotai/utils';

// Custom storage adapter using js-cookie for better security
const cookieStorage = {
  getItem: (key: string): string | null => {
    const value = Cookies.get(key);
    return value ?? null;
  },
  setItem: (key: string, value: string): void => {
    // HttpOnly cookies require server cooperation; for client-accessible
    // tokens, use Secure + SameSite flags via js-cookie
    Cookies.set(key, value, { 
      secure: true, 
      sameSite: 'strict',
      expires: 7 // 7 days for refresh tokens
    });
  },
  removeItem: (key: string): void => {
    Cookies.remove(key);
  },
};

// Atom for access token (short-lived, e.g., 15 minutes)
export const accessTokenAtom = atomWithStorage(
  'auth:accessToken',
  null,
  cookieStorage
);

// Atom for refresh token (long-lived, e.g., 7 days)
export const refreshTokenAtom = atomWithStorage(
  'auth:refreshToken',
  null,
  cookieStorage
);

// Combined tokens atom for convenience
export const authTokensAtom = atom((get) => ({
  accessToken: get(accessTokenAtom),
  refreshToken: get(refreshTokenAtom),
}));

Decoding JWT Claims with Derived Atoms

Rather than decoding the token in every component, create a derived atom that extracts user information from the JWT payload. This atom automatically recomputes whenever the access token changes:

// atoms/auth.ts (continued)
import jwtDecode from 'jwt-decode';

// Interface for decoded JWT payload
interface JwtPayload {
  sub: string;
  email: string;
  name: string;
  exp: number;
  iat: number;
  roles: string[];
}

// Derived atom that decodes the token and extracts user profile
export const userProfileAtom = atom((get) => {
  const accessToken = get(accessTokenAtom);
  
  if (!accessToken) return null;
  
  try {
    const decoded = jwtDecode(accessToken);
    
    // Validate expiration
    const currentTime = Math.floor(Date.now() / 1000);
    if (decoded.exp && decoded.exp < currentTime) {
      // Token expired — will be handled by refresh logic
      return null;
    }
    
    return {
      id: decoded.sub,
      email: decoded.email,
      name: decoded.name,
      roles: decoded.roles || [],
    };
  } catch (error) {
    console.error('Failed to decode JWT:', error);
    return null;
  }
});

// Computed atom for expiration timestamp
export const tokenExpirationAtom = atom((get) => {
  const accessToken = get(accessTokenAtom);
  if (!accessToken) return null;
  
  try {
    const decoded = jwtDecode(accessToken);
    return decoded.exp ? decoded.exp * 1000 : null; // Convert to milliseconds
  } catch {
    return null;
  }
});

Building the Token Refresh Mechanism

Token refresh is critical for maintaining sessions without forcing users to re-login. Use atomWithRefresh to create a refreshable atom that automatically renews the access token when it approaches expiration:

// atoms/auth.ts (continued)
import { atomWithRefresh } from 'jotai/utils';

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.example.com';

// Refreshable atom that guarantees a valid access token
export const validAccessTokenAtom = atomWithRefresh(
  async (get, { signal }) => {
    const accessToken = get(accessTokenAtom);
    const refreshToken = get(refreshTokenAtom);
    
    // If no tokens exist, return null (user must log in)
    if (!accessToken && !refreshToken) return null;
    
    // Check if access token is still valid with a 60-second buffer
    if (accessToken) {
      try {
        const decoded = jwtDecode(accessToken);
        const currentTime = Math.floor(Date.now() / 1000);
        const bufferSeconds = 60;
        
        if (decoded.exp && decoded.exp - currentTime > bufferSeconds) {
          return accessToken; // Token is still valid
        }
      } catch {
        // Decode failed, proceed to refresh
      }
    }
    
    // Attempt refresh if refresh token exists
    if (!refreshToken) return null;
    
    try {
      const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ refreshToken }),
        signal,
      });
      
      if (!response.ok) {
        // Refresh failed — clear tokens and force login
        throw new Error('Token refresh failed');
      }
      
      const data = await response.json();
      const newAccessToken: string = data.accessToken;
      const newRefreshToken: string | undefined = data.refreshToken;
      
      // Update persistent atoms
      // Note: We use the setter functions outside the async getter
      // by scheduling updates via write-only atoms (covered next)
      
      return newAccessToken;
    } catch (error) {
      if (error instanceof DOMException && error.name === 'AbortError') {
        throw error; // Re-throw abort errors
      }
      // Refresh failed — clear tokens
      return null;
    }
  }
);

To handle token updates after refresh, create write actions that update the persistent storage atoms:

// atoms/auth.ts (continued)

// Action atom for updating tokens after refresh
export const updateTokensAtom = atom(
  null,
  (get, set, { accessToken, refreshToken }: AuthTokens) => {
    set(accessTokenAtom, accessToken);
    set(refreshTokenAtom, refreshToken);
  }
);

// Action atom for clearing all auth state (logout)
export const clearAuthAtom = atom(null, (get, set) => {
  set(accessTokenAtom, null);
  set(refreshTokenAtom, null);
});

// Combined refresh handler with token persistence
export const refreshAndStoreTokensAtom = atom(null, async (get, set) => {
  const currentRefreshToken = get(refreshTokenAtom);
  if (!currentRefreshToken) {
    set(clearAuthAtom);
    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) {
      set(clearAuthAtom);
      return;
    }
    
    const data = await response.json();
    set(updateTokensAtom, {
      accessToken: data.accessToken,
      refreshToken: data.refreshToken || currentRefreshToken,
    });
  } catch {
    set(clearAuthAtom);
  }
});

Authentication Status Derived Atom

Combine everything into a single authStatusAtom that components can subscribe to for rendering decisions:

// atoms/auth.ts (continued)

export const authStatusAtom = atom((get) => {
  const accessToken = get(accessTokenAtom);
  const refreshToken = get(refreshTokenAtom);
  const profile = get(userProfileAtom);
  
  // No tokens at all — user is idle (not authenticated)
  if (!accessToken && !refreshToken) return 'idle';
  
  // Has access token and valid profile — authenticated
  if (accessToken && profile) return 'authenticated';
  
  // Has refresh token but no valid access token — needs refresh
  if (refreshToken && !profile) return 'expired';
  
  return 'loading';
});

// Convenience boolean atom for conditional rendering
export const isAuthenticatedAtom = atom(
  (get) => get(authStatusAtom) === 'authenticated'
);

Session Management with Jotai

Sessions extend beyond simple token presence. They encompass user activity tracking, session expiry handling, and graceful degradation when connectivity is lost. Jotai's atomic model excels here because you can compose session-aware atoms without coupling them to UI components.

Creating a Session Lifecycle Atom

A session atom tracks the complete lifecycle: login time, last activity, and automatic expiration based on inactivity:

// atoms/session.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { authStatusAtom, clearAuthAtom, refreshAndStoreTokensAtom } from './auth';

export interface SessionState {
  initiatedAt: number | null;       // Timestamp of login
  lastActivityAt: number | null;   // Last user interaction timestamp
  expiresAt: number | null;        // Absolute session expiry
  idleTimeoutMs: number;           // Configurable idle timeout (default 30 min)
}

// Persistent session metadata
export const sessionAtom = atomWithStorage(
  'auth:session',
  {
    initiatedAt: null,
    lastActivityAt: null,
    expiresAt: null,
    idleTimeoutMs: 30 * 60 * 1000, // 30 minutes
  }
);

// Atom to update last activity timestamp
export const touchSessionAtom = atom(null, (get, set) => {
  const session = get(sessionAtom);
  set(sessionAtom, {
    ...session,
    lastActivityAt: Date.now(),
  });
});

// Derived atom: is the session expired due to inactivity?
export const isSessionIdleAtom = atom((get) => {
  const session = get(sessionAtom);
  const authStatus = get(authStatusAtom);
  
  if (authStatus !== 'authenticated') return false;
  if (!session.lastActivityAt) return false;
  
  const idleDeadline = session.lastActivityAt + session.idleTimeoutMs;
  return Date.now() > idleDeadline;
});

// Derived atom: remaining session time in milliseconds
export const sessionTimeRemainingAtom = atom((get) => {
  const session = get(sessionAtom);
  if (!session.expiresAt) return null;
  
  const remaining = session.expiresAt - Date.now();
  return remaining > 0 ? remaining : 0;
});

Auto-Logout on Session Expiry

Implement an atom effect that monitors session state and triggers logout when the session expires. This uses Jotai's onMount hook within atoms:

// atoms/session.ts (continued)

// Atom with effect for auto-logout monitoring
export const sessionMonitorAtom = atom(null, (get, set) => {
  const isIdle = get(isSessionIdleAtom);
  const timeRemaining = get(sessionTimeRemainingAtom);
  const authStatus = get(authStatusAtom);
  
  // Only monitor when authenticated
  if (authStatus !== 'authenticated') return;
  
  // Trigger logout on idle timeout
  if (isIdle) {
    console.log('Session expired due to inactivity');
    set(clearAuthAtom);
    set(sessionAtom, {
      initiatedAt: null,
      lastActivityAt: null,
      expiresAt: null,
      idleTimeoutMs: 30 * 60 * 1000,
    });
  }
  
  // Warning state when less than 5 minutes remain
  if (timeRemaining !== null && timeRemaining < 5 * 60 * 1000) {
    // Set warning flag for UI notification
    // (You can create a dedicated warning atom for this)
  }
});

// Activity tracking atom — call this on user interactions
export const recordUserActivityAtom = atom(null, (get, set) => {
  const authStatus = get(authStatusAtom);
  if (authStatus !== 'authenticated') return;
  
  set(touchSessionAtom);
});

Using Session Atoms in Components

Wire up session tracking in your root component or a dedicated session provider:

// components/SessionProvider.tsx
import { useEffect, useCallback } from 'react';
import { useAtom, useSetAtom, useAtomValue } from 'jotai';
import { 
  sessionMonitorAtom, 
  recordUserActivityAtom,
  isSessionIdleAtom,
  sessionTimeRemainingAtom 
} from '../atoms/session';
import { authStatusAtom } from '../atoms/auth';

export function SessionProvider({ children }: { children: React.ReactNode }) {
  const authStatus = useAtomValue(authStatusAtom);
  const isIdle = useAtomValue(isSessionIdleAtom);
  const timeRemaining = useAtomValue(sessionTimeRemainingAtom);
  const recordActivity = useSetAtom(recordUserActivityAtom);
  const [, monitor] = useAtom(sessionMonitorAtom);
  
  // Monitor session state
  useEffect(() => {
    if (authStatus === 'authenticated') {
      monitor();
    }
  }, [authStatus, monitor]);
  
  // Track user activity across the application
  const handleActivity = useCallback(() => {
    recordActivity();
  }, [recordActivity]);
  
  useEffect(() => {
    const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
    events.forEach(event => window.addEventListener(event, handleActivity));
    
    return () => {
      events.forEach(event => window.removeEventListener(event, handleActivity));
    };
  }, [handleActivity]);
  
  // Show warning modal when session is about to expire
  // (Implementation depends on your UI framework)
  
  return <>{children};
}

OAuth Integration with Jotai

OAuth 2.0 integration introduces redirect flows, code exchange, and provider-specific token handling. Jotai's async atom capabilities make it ideal for managing the multi-step OAuth process without resorting to complex middleware patterns.

OAuth Provider Configuration

Define atoms for OAuth provider configuration and state management:

// atoms/oauth.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { 
  accessTokenAtom, 
  refreshTokenAtom, 
  updateTokensAtom,
  authStatusAtom 
} from './auth';

// Supported OAuth providers
export type OAuthProvider = 'google' | 'github' | 'apple' | 'microsoft';

export interface OAuthConfig {
  provider: OAuthProvider;
  clientId: string;
  redirectUri: string;
  scope: string;
  authorizationUrl: string;
  tokenExchangeUrl: string;
}

// OAuth provider configurations
export const oauthConfigsAtom = atom>({
  google: {
    provider: 'google',
    clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
    redirectUri: `${window.location.origin}/auth/callback/google`,
    scope: 'openid email profile',
    authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
    tokenExchangeUrl: `${process.env.NEXT_PUBLIC_API_URL}/auth/oauth/google`,
  },
  github: {
    provider: 'github',
    clientId: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID!,
    redirectUri: `${window.location.origin}/auth/callback/github`,
    scope: 'user:email',
    authorizationUrl: 'https://github.com/login/oauth/authorize',
    tokenExchangeUrl: `${process.env.NEXT_PUBLIC_API_URL}/auth/oauth/github`,
  },
  apple: {
    provider: 'apple',
    clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID!,
    redirectUri: `${window.location.origin}/auth/callback/apple`,
    scope: 'name email',
    authorizationUrl: 'https://appleid.apple.com/auth/authorize',
    tokenExchangeUrl: `${process.env.NEXT_PUBLIC_API_URL}/auth/oauth/apple`,
  },
  microsoft: {
    provider: 'microsoft',
    clientId: process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID!,
    redirectUri: `${window.location.origin}/auth/callback/microsoft`,
    scope: 'openid profile email User.Read',
    authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
    tokenExchangeUrl: `${process.env.NEXT_PUBLIC_API_URL}/auth/oauth/microsoft`,
  },
});

OAuth State Machine Atom

The OAuth flow is inherently a state machine: idle → initiating → redirecting → exchanging → complete (or failed). Model this with atoms:

// atoms/oauth.ts (continued)

export type OAuthStep = 
  | 'idle'           // Not started
  | 'initiating'     // Preparing redirect
  | 'redirecting'    // User is being redirected to provider
  | 'callback'       // User returned with authorization code
  | 'exchanging'     // Exchanging code for tokens
  | 'authenticated'  // Successfully authenticated
  | 'error';         // Authentication failed

export interface OAuthState {
  step: OAuthStep;
  provider: OAuthProvider | null;
  codeVerifier: string | null;    // PKCE code verifier
  stateParam: string | null;      // Anti-CSRF state parameter
  error: string | null;
  initiatedAt: number | null;
}

// OAuth flow state atom (not persisted — ephemeral flow state)
export const oauthStateAtom = atom({
  step: 'idle',
  provider: null,
  codeVerifier: null,
  stateParam: null,
  error: null,
  initiatedAt: null,
});

// Convenience atoms for specific OAuth steps
export const isOAuthRedirectingAtom = atom(
  (get) => get(oauthStateAtom).step === 'redirecting'
);

export const oauthErrorAtom = atom(
  (get) => get(oauthStateAtom).error
);

PKCE Code Verifier and Challenge Generation

For enhanced security, implement Proof Key for Code Exchange (PKCE) in your OAuth flows. Generate the code verifier and challenge as reactive atoms:

// atoms/oauth.ts (continued)

// Utility function to generate PKCE code verifier
function generateCodeVerifier(): string {
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  const randomValues = new Uint8Array(64);
  crypto.getRandomValues(randomValues);
  
  return Array.from(randomValues)
    .map((byte) => charset[byte % charset.length])
    .join('');
}

// Utility function to generate code challenge from verifier
async function generateCodeChallenge(verifier: string): Promise {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  
  // Base64url encoding
  const hashArray = Array.from(new Uint8Array(hash));
  const base64 = btoa(String.fromCharCode(...hashArray));
  return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// Atom to generate PKCE pair
export const generatePkcePairAtom = atom(null, async (get, set) => {
  const verifier = generateCodeVerifier();
  const challenge = await generateCodeChallenge(verifier);
  
  set(oauthStateAtom, (prev) => ({
    ...prev,
    codeVerifier: verifier,
    stateParam: crypto.randomUUID(), // Anti-CSRF state
  }));
  
  return { verifier, challenge };
});

Initiating OAuth Login Flow

Create an action atom that constructs the authorization URL and triggers the redirect:

// atoms/oauth.ts (continued)

// Action atom to initiate OAuth login
export const initiateOAuthLoginAtom = atom(
  null,
  async (get, set, provider: OAuthProvider) => {
    const config = get(oauthConfigsAtom)[provider];
    if (!config) {
      set(oauthStateAtom, (prev) => ({
        ...prev,
        step: 'error',
        error: `Unknown provider: ${provider}`,
      }));
      return;
    }
    
    try {
      set(oauthStateAtom, (prev) => ({
        ...prev,
        step: 'initiating',
        provider,
        initiatedAt: Date.now(),
      }));
      
      // Generate PKCE pair
      const { challenge } = await set(generatePkcePairAtom);
      const stateParam = get(oauthStateAtom).stateParam;
      
      // Store state parameter for CSRF verification
      // In production, store in sessionStorage or a secure cookie
      sessionStorage.setItem('oauth:state', stateParam!);
      sessionStorage.setItem('oauth:provider', provider);
      
      // Build authorization URL
      const params = new URLSearchParams({
        response_type: 'code',
        client_id: config.clientId,
        redirect_uri: config.redirectUri,
        scope: config.scope,
        state: stateParam!,
        code_challenge: challenge,
        code_challenge_method: 'S256',
      });
      
      const authUrl = `${config.authorizationUrl}?${params.toString()}`;
      
      set(oauthStateAtom, (prev) => ({
        ...prev,
        step: 'redirecting',
      }));
      
      // Redirect the user
      window.location.href = authUrl;
    } catch (error) {
      set(oauthStateAtom, (prev) => ({
        ...prev,
        step: 'error',
        error: 'Failed to initiate OAuth flow',
      }));
    }
  }
);

Handling the OAuth Callback

After the user returns with an authorization code, exchange it for tokens via your backend API:

// atoms/oauth.ts (continued)

// Action atom to handle OAuth callback
export const handleOAuthCallbackAtom = atom(
  null,
  async (get, set, callbackCode: string, returnedState: string) => {
    // Verify CSRF state parameter
    const storedState = sessionStorage.getItem('oauth:state');
    const storedProvider = sessionStorage.getItem('oauth:provider') as OAuthProvider | null;
    
    if (!storedState || storedState !== returnedState) {
      set(oauthStateAtom, (prev) => ({
        ...prev,
        step: 'error',
        error: 'CSRF validation failed — state mismatch',
      }));
      return;
    }
    
    if (!storedProvider) {
      set(oauthStateAtom, (prev) => ({
        ...prev,
        step: 'error',
        error: 'Provider information lost — restart flow',
      }));
      return;
    }
    
    const config = get(oauthConfigsAtom)[storedProvider];
    const codeVerifier = get(oauthStateAtom).codeVerifier;
    
    set(oauthStateAtom, (prev) => ({
      ...prev,
      step: 'exchanging',
      provider: storedProvider,
    }));
    
    try {
      const response = await fetch(config.tokenExchangeUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          code: callbackCode,
          code_verifier: codeVerifier,
          redirect_uri: config.redirectUri,
        }),
      });
      
      if (!response.ok) {
        const errorData = await response.json().catch(() => ({}));
        throw new Error(errorData.message || 'Token exchange failed');
      }
      
      const data = await response.json();
      
      // Persist tokens using our auth atoms
      set(updateTokensAtom, {
        accessToken: data.accessToken,
        refreshToken: data.refreshToken,
      });
      
      // Clear OAuth ephemeral state
      set(oauthStateAtom, {
        step: 'authenticated',
        provider: storedProvider,
        codeVerifier: null,
        stateParam: null,
        error: null,
        initiatedAt: null,
      });
      
      // Clean up sessionStorage
      sessionStorage.removeItem('oauth:state');
      sessionStorage.removeItem('oauth:provider');
      
      // Initialize session tracking
      const { sessionAtom } = await import('./session');
      set(sessionAtom, {
        initiatedAt: Date.now(),
        lastActivityAt: Date.now(),
        expiresAt: null, // Set based on refresh token expiry
        idleTimeoutMs: 30 * 60 * 1000,
      });
      
    } catch (error) {
      set(oauthStateAtom, (prev) => ({
        ...prev,
        step: 'error',
        error: error instanceof Error ? error.message : 'Token exchange failed',
      }));
    }
  }
);

OAuth Callback Component

Create a route handler component that processes the OAuth redirect:

// components/OAuthCallback.tsx
import { useEffect } from 'react';
import { useSetAtom, useAtomValue } from 'jotai';
import { handleOAuthCallbackAtom, oauthStateAtom } from '../atoms/oauth';
import { useNavigate } from 'react-router-dom'; // or Next.js router

export function OAuthCallback() {
  const navigate = useNavigate();
  const handleCallback = useSetAtom(handleOAuthCallbackAtom);
  const oauthState = useAtomValue(oauthStateAtom);
  
  useEffect(() => {
    // Extract code and state from URL parameters
    const params = new URLSearchParams(window.location.search);
    const code = params.get('code');
    const state = params.get('state');
    const error = params.get('error');
    
    if (error) {
      console.error('OAuth provider returned error:', error);
      navigate('/login?error=oauth_denied');
      return;
    }
    
    if (code && state) {
      handleCallback(code, state)
        .then(() => {
          navigate('/dashboard');
        })
        .catch((err) => {
          console.error('Callback handling failed:', err);
          navigate('/login?error=callback_failed');
        });
    } else {
      navigate('/login?error=missing_params');
    }
  }, []);
  
  return (
    

Completing authentication...

{oauthState.step === 'error' && (

Error: {oauthState.error}

)}
); }

Multi-Provider OAuth UI Component

Build a login component that leverages all OAuth atoms for a unified experience:

// components/OAuthLoginButtons.tsx
import { useSetAtom, useAtomValue } from 'jotai';
import { initiateOAuthLoginAtom, oauthStateAtom, oauthErrorAtom } from '../atoms/oauth';
import type { OAuthProvider } from '../atoms/oauth';

export function OAuthLoginButtons() {
  const initiateLogin = useSetAtom(initiateOAuthLoginAtom);
  const oauthState = useAtomValue(oauthStateAtom);
  const error = useAtomValue(oauthErrorAtom);
  
  const handleProviderLogin = async (provider: OAuthProvider) => {
    await initiateLogin(provider);
  };
  
  const isProcessing = oauthState.step === 'initiating' || 
                       oauthState.step === 'redirecting';
  
  return (
    

Sign in with

{error && (
{error}
)}
); }

Putting It All Together: The Auth Guard Component

Combine JWT validation, session tracking, and OAuth state into a unified auth guard that protects routes and provides context-aware rendering:

// components/AuthGuard.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import { 
  authStatusAtom, 
  isAuthenticatedAtom, 
  userProfileAtom,
  refreshAndStoreTokensAtom,
  clearAuthAtom 
} from '../atoms/auth';
import { 
  sessionTimeRemainingAtom, 
  isSessionIdleAtom,
  recordUserActivityAtom 
} from '../atoms/session';
import { oauthStateAtom } from '../atoms/oauth';

interface AuthGuardProps {
  children: React.ReactNode;
  fallback?: React.ReactNode;
  requireRoles?: string[];
}

export function AuthGuard({ 
  children, 
  fallback,
  requireRoles 
}: AuthGuardProps) {
  const authStatus = useAtomValue(authStatusAtom);
  const isAuthenticated = useAtomValue(isAuthenticatedAtom);
  const profile = useAtomValue(userProfileAtom);
  const timeRemaining = useAtomValue(sessionTimeRemainingAtom);
  const isIdle = useAtomValue(isSessionIdleAtom);
  const oauthState = useAtomValue(oauthStateAtom);
  
  const refreshTokens = useSetAtom(refreshAndStoreTokensAtom);
  const logout = useSetAtom(clearAuthAtom);
  const recordActivity = useSetAtom(recordUserActivityAtom);
  
  // Handle expired token state — attempt refresh
  if (authStatus === 'expired') {
    // Trigger refresh and show loading
    refreshTokens();
    return 
Refreshing session...
; } // Handle OAuth in progress if (oauthState.step === 'redirecting' || oauthState.step === 'exchanging') { return
Completing authentication...
; } // Not authenticated — show fallback or redirect if (!isAuthenticated) { return fallback ??
Please log in to access this resource.
; } // Session idle warning if (isIdle) { logout(); return
Session expired due to inactivity. Please log in again.
; } // Role-based access control if (requireRoles && profile) { const hasRequiredRoles = requireRoles.some(role => profile.roles.includes(role) ); if (!hasRequiredRoles) { return
Insufficient permissions to access this resource.
; } } // Session time warning (less than 5 minutes) if (timeRemaining !== null && timeRemaining < 5 * 60 * 1000) { return (
Your session expires in {Math.floor(timeRemaining / 60000)} minutes.
{children}
); } // All checks passed — render children with activity tracking return (
{children}
); }

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles