Overview
Authentication is critical for securing your application and managing user access. This guide covers modern authentication patterns, JWT handling, protected routes, and best practices.Authentication Strategies
- JWT (Recommended)
- Session-Based
- OAuth/Social Login
JSON Web Tokens are stateless and work well with modern APIs.Pros:
- Stateless authentication
- Works across multiple domains
- Mobile-friendly
- Scalable
- Cannot revoke tokens before expiry
- Tokens can be large
- Need refresh token strategy
Traditional approach using server-side sessions.Pros:
- Easy to revoke
- Server controls session
- Familiar pattern
- Requires server state
- CORS complexity
- Scaling challenges
Delegate authentication to third-party providers.Pros:
- No password management
- Better UX
- Trusted providers
- External dependency
- Limited control
- Privacy concerns
Setting Up Authentication Context
Create Auth Context
AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface User {
id: string;
email: string;
name: string;
role: string;
}
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (email: string, password: string, name: string) => Promise<void>;
isLoading: boolean;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check if user is logged in on mount
checkAuth();
}, []);
const checkAuth = async () => {
try {
const token = localStorage.getItem('accessToken');
if (!token) {
setIsLoading(false);
return;
}
const response = await fetch('/api/auth/me', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
} else {
localStorage.removeItem('accessToken');
}
} catch (error) {
console.error('Auth check failed:', error);
} finally {
setIsLoading(false);
}
};
const login = async (email: string, password: string) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const { user, accessToken, refreshToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
};
const logout = async () => {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
},
});
} catch (error) {
console.error('Logout failed:', error);
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
}
};
const register = async (email: string, password: string, name: string) => {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
if (!response.ok) {
throw new Error('Registration failed');
}
const { user, accessToken, refreshToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
};
return (
<AuthContext.Provider
value={{
user,
login,
logout,
register,
isLoading,
isAuthenticated: !!user,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
Wrap App with Provider
App.tsx
import { AuthProvider } from './AuthContext';
function App() {
return (
<AuthProvider>
<YourApp />
</AuthProvider>
);
}
Use in Components
Profile.tsx
import { useAuth } from './AuthContext';
export function Profile() {
const { user, logout, isLoading } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!user) return <div>Not logged in</div>;
return (
<div>
<h1>Welcome, {user.name}</h1>
<p>{user.email}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
Store sensitive tokens in httpOnly cookies when possible. Use localStorage only for non-sensitive data or when cookies aren’t an option.
Login Form Implementation
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useAuth } from './AuthContext';
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export function LoginForm() {
const { login } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
setError(null);
await login(data.email, data.password);
navigate('/dashboard');
} catch (err) {
setError('Invalid email or password');
}
};
return (
<div className="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow">
<h2 className="text-2xl font-bold mb-6">Sign In</h2>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded">
<p className="text-red-800 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
{...register('email')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Password
</label>
<input
type="password"
{...register('password')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div className="flex items-center justify-between">
<label className="flex items-center">
<input type="checkbox" className="rounded border-gray-300" />
<span className="ml-2 text-sm text-gray-600">Remember me</span>
</label>
<a href="/forgot-password" className="text-sm text-blue-600 hover:underline">
Forgot password?
</a>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-600">
Don't have an account?{' '}
<a href="/register" className="text-blue-600 hover:underline">
Sign up
</a>
</p>
</div>
);
}
Protected Routes
ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: string;
}
export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
const { user, isLoading, isAuthenticated } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requiredRole && user?.role !== requiredRole) {
return <Navigate to="/unauthorized" replace />;
}
return <>{children}</>;
}
routes.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ProtectedRoute } from './ProtectedRoute';
function AppRoutes() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route path="/register" element={<RegisterForm />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute requiredRole="admin">
<AdminPanel />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
}
Always validate permissions on the server. Client-side protection is for UX only and can be bypassed.
Token Refresh Pattern
api-client.ts
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];
function subscribeTokenRefresh(callback: (token: string) => void) {
refreshSubscribers.push(callback);
}
function onTokenRefreshed(token: string) {
refreshSubscribers.forEach((callback) => callback(token));
refreshSubscribers = [];
}
async function refreshAccessToken(): Promise<string> {
const refreshToken = localStorage.getItem('refreshToken');
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
throw new Error('Token refresh failed');
}
const { accessToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
return accessToken;
}
export async function authenticatedFetch(url: string, options: RequestInit = {}) {
let accessToken = localStorage.getItem('accessToken');
const makeRequest = async (token: string) => {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
};
let response = await makeRequest(accessToken!);
// If token expired, refresh and retry
if (response.status === 401) {
if (!isRefreshing) {
isRefreshing = true;
try {
const newToken = await refreshAccessToken();
isRefreshing = false;
onTokenRefreshed(newToken);
response = await makeRequest(newToken);
} catch (error) {
isRefreshing = false;
throw error;
}
} else {
// Wait for token refresh to complete
const newToken = await new Promise<string>((resolve) => {
subscribeTokenRefresh(resolve);
});
response = await makeRequest(newToken);
}
}
return response;
}
Token refresh logic should handle race conditions when multiple requests fail simultaneously. Queue requests during refresh.
Social Authentication
SocialLogin.tsx
export function SocialLogin() {
const handleGoogleLogin = () => {
window.location.href = '/api/auth/google';
};
const handleGitHubLogin = () => {
window.location.href = '/api/auth/github';
};
return (
<div className="space-y-3">
<button
onClick={handleGoogleLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
<img src="/google-icon.svg" alt="" className="w-5 h-5" />
Continue with Google
</button>
<button
onClick={handleGitHubLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
<img src="/github-icon.svg" alt="" className="w-5 h-5" />
Continue with GitHub
</button>
</div>
);
}
Best Practices
- Never store sensitive data in localStorage (use httpOnly cookies when possible)
- Always use HTTPS in production
- Implement CSRF protection for session-based auth
- Use secure, httpOnly cookies for tokens
- Implement rate limiting on auth endpoints
- Add multi-factor authentication for sensitive operations
- Log authentication events for security monitoring
- Use strong password requirements
- Implement account lockout after failed attempts
- Provide password reset functionality
Next Steps
- Secure API calls with Data Fetching patterns
- Build auth forms with Form Handling
- Deploy securely with Production Setup