React Authentication & Role-Based Access Control
Role-based access control (RBAC) in React ensures that users see only features and data they're authorized for. This article teaches you to combine JWT tokens, context-based state management, and React Router to build a secure authentication layer that keeps malicious users out and honest users in the right lanes.
I've debugged authentication bugs in three shipped SaaS products and learned that the majority come from token handling on the client—forgotten refreshes, missing expiry checks, and localStorage vulnerabilities. We'll address all three.
What You'll Learn
You'll build:
- A login flow that exchanges credentials for JWT access and refresh tokens
- A custom React hook that decodes tokens and checks expiry without a request
- Protected routes that redirect unauthorized users to login
- Role-based route guards (e.g., only admins see the billing page)
- Automatic token refresh when the access token expires
Prerequisites
This article assumes you have the dashboard scaffold from the previous article. You need React Router v6 installed (npm install react-router-dom). Understanding of JWT structure and HTTP headers is helpful but not required.
Step 1: Create an Authentication Context
A React context holds the current user, token, and auth methods. Create src/context/AuthContext.tsx:
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
export interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'user' | 'viewer';
}
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
hasRole: (role: string) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem('access_token'));
const [isLoading, setIsLoading] = useState(false);
// Decode JWT manually (secure decoding happens on the server)
const decodeToken = useCallback((token: string): User | null => {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const decoded = JSON.parse(atob(parts[1]));
return {
id: decoded.sub,
email: decoded.email,
name: decoded.name,
role: decoded.role,
};
} catch {
return null;
}
}, []);
// Load user from stored token on mount
useEffect(() => {
if (token) {
const decoded = decodeToken(token);
setUser(decoded);
}
}, [token, decodeToken]);
const login = useCallback(async (email: string, password: string) => {
setIsLoading(true);
try {
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 data = await response.json();
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
setToken(data.access_token);
} finally {
setIsLoading(false);
}
}, []);
const logout = useCallback(() => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
setToken(null);
setUser(null);
}, []);
const hasRole = useCallback((role: string) => {
return user?.role === role;
}, [user]);
return (
<AuthContext.Provider value={{ user, token, isLoading, login, logout, hasRole }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) throw new Error('useAuth must be used within AuthProvider');
return context;
}
This context provides a useAuth hook that any component can call. The decodeToken function extracts user info from the JWT payload. In a real app, never trust the client-side decode—always verify on the server.
Step 2: Create Protected Routes
Create src/components/ProtectedRoute.tsx:
import { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
interface ProtectedRouteProps {
children: ReactNode;
requiredRole?: string;
}
export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
const { user, token } = useAuth();
if (!token) {
return <Navigate to="/login" replace />;
}
if (requiredRole && !user || user.role !== requiredRole) {
return <Navigate to="/unauthorized" replace />;
}
return <>{children}</>;
}
This component wraps route children and redirects unauthenticated or unauthorized users. For example, in src/App.tsx:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Billing from './pages/Billing';
import Unauthorized from './pages/Unauthorized';
function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/unauthorized" element={<Unauthorized />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/billing"
element={
<ProtectedRoute requiredRole="admin">
<Billing />
</ProtectedRoute>
}
/>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
export default App;
Step 3: Build a Login Page
Create src/pages/Login.tsx:
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login, isLoading } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(email, password);
navigate('/dashboard');
} catch (err) {
setError('Invalid credentials. Try [email protected] / password123');
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Sign In</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
);
}
For testing, your backend should accept [email protected] / password123 and return a valid JWT. In production, use HTTPS and hash passwords with bcrypt.
Step 4: Implement Token Refresh
Tokens expire, usually in 15 minutes. Create src/hooks/useTokenRefresh.ts:
import { useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
export function useTokenRefresh() {
const { token } = useAuth();
useEffect(() => {
if (!token) return;
// Decode and check expiry
const parts = token.split('.');
if (parts.length !== 3) return;
try {
const decoded = JSON.parse(atob(parts[1]));
const expiresAt = decoded.exp * 1000; // exp is in seconds
const now = Date.now();
const timeUntilExpiry = expiresAt - now;
// Refresh when 1 minute remains
const timeout = timeUntilExpiry - 60000;
if (timeout <= 0) {
refreshToken();
return;
}
const timer = setTimeout(refreshToken, timeout);
return () => clearTimeout(timer);
} catch {
// Invalid token, will redirect to login
}
}, [token]);
async function refreshToken() {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) return;
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('access_token', data.access_token);
// Token context updates, useEffect re-runs
} else {
// Refresh failed, redirect to login (logout called elsewhere)
}
} catch {
console.error('Token refresh failed');
}
}
}
Call this hook in your root App component to keep tokens fresh for the entire session.
Common RBAC Patterns
| Pattern | Use Case | Example |
|---|---|---|
| Route guards | Entire page behind a role | /admin requires admin role |
| Component rendering | Show/hide UI elements | {user?.role === 'admin' && <DeleteButton />} |
| Data filtering | API queries respect roles | /api/users returns only accessible users |
| Audit logging | Track role changes | Log when user.role changes from user to admin |
Key Takeaways
- Store tokens in
localStoragefor persistence across page reloads, but never access them from client-side JavaScript that external scripts can reach (XSS risk). Use httpOnly cookies if your backend sets them. - Always verify tokens on the backend—the client-side decode is for UI logic only, not authorization.
- Refresh tokens automatically before expiry to avoid mid-session logouts that frustrate users.
- Role checks (
hasRole('admin')) should guard both routes and UI elements; don't rely on UI hiding alone. - For sensitive operations (billing, data deletion), always confirm authorization on the server, not just the client.
Frequently Asked Questions
Why store tokens in localStorage instead of memory?
localStorage persists across page reloads and tab closures, so users stay logged in. Memory-only tokens log users out on every refresh, which is annoying. Trade-off: localStorage is vulnerable to XSS if an attacker injects JavaScript. Mitigate with Content Security Policy (CSP) headers and by never eval-ing user input.
Can I use cookies instead of localStorage?
Yes, and many prefer them (easier to set httpOnly and Secure flags on the server). If your backend sets a cookie, you don't need to handle storage on the client—the browser does it automatically. The trade-off: cookies are sent on every request, adding bytes; localStorage requires manual header setting.
What if a user's role changes while they're logged in?
Their token won't update until the next refresh. For critical permission changes (e.g., revoking admin), either invalidate the refresh token on the server or prompt the user to re-login. For non-critical changes, the eventual consistency is acceptable.
Should I show the "Unauthorized" page or redirect silently?
Show a clear message. Redirecting silently (e.g., /dashboard silently goes to /users) confuses users. A message like "You don't have permission to view billing settings" helps them understand why a feature disappeared.
How do I test protected routes?
Mock the useAuth hook in tests. For example, in a Jest setup: jest.mock('./context/AuthContext', () => ({ useAuth: () => ({ user: { role: 'admin' }, token: 'fake' }) })). Then assert that the admin-only route renders.