Skip to content

How do I implement authentication and role-based access control in React?

I kept building todo apps and weather widgets, thinking I was “ready” for React developer jobs. Then I hit my first real project with actual users, and everything fell apart. The login form worked great in isolation. But when I needed to protect routes, handle expired tokens, and show different UI based on user roles? That’s when I realized authentication in React is way more than a simple login page.

Let me walk through what I learned implementing authentication and role-based access control (RBAC) the hard way.

The Problem With Most Auth Tutorials

Most authentication tutorials stop at “here’s a login form that sets a token in localStorage.” They don’t cover:

  • What happens when a user refreshes the page
  • How to protect routes from unauthenticated access
  • What to do when tokens expire mid-session
  • How to restrict UI elements based on user roles

I built a login form, stored the JWT in localStorage, and thought I was done. Then users started complaining about being logged out randomly, seeing pages they shouldn’t access, and getting errors on protected API calls.

Understanding the Full Authentication Lifecycle

Authentication in React isn’t just about logging in. It’s about managing the entire session lifecycle:

  1. Initial load - Check if user has a valid session
  2. Login - Acquire tokens and establish session
  3. Session maintenance - Keep tokens fresh
  4. Route protection - Guard authenticated routes
  5. Role enforcement - Control what users can see and do
  6. Logout - Clean up session properly

Each piece needs to work together, and missing any one of them breaks the user experience.

Starting With Token Management

My first mistake was treating JWT tokens as simple strings to throw in localStorage. The reality is more nuanced.

Where to Store Tokens

I initially used localStorage because it was easy. Then I learned about XSS attacks. If any third-party script on your page can read localStorage, it can steal your tokens.

The safer approach is httpOnly cookies set by your backend. Your React app can’t read them, but they’re automatically sent with requests. This eliminates XSS token theft as an attack vector.

If you must use localStorage (for example, if your API is on a different domain), you need to be extra careful about XSS protection:

  • Implement strict Content Security Policy headers
  • Sanitize all user inputs
  • Audit third-party scripts regularly

Token Refresh Strategy

JWTs expire. That’s the point. But users hate being logged out while actively using your app.

I learned to implement automatic token refresh. The pattern I use:

  1. Decode the JWT to check its expiration time
  2. Set a timer to refresh the token a few minutes before expiration
  3. If a request fails with 401, try refreshing once before logging out

Here’s how I handle the Axios interceptor:

api/axiosConfig.ts
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL
})
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const newToken = await refreshToken()
originalRequest.headers.Authorization = `Bearer ${newToken}`
return api(originalRequest)
} catch (refreshError) {
logout()
window.location.href = '/login'
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
}
)

Building the Authentication State

I tried using local component state for authentication. That broke when I needed to check auth status from multiple components. The solution is global state, either React Context or Redux.

The Auth Slice Structure

My authentication state needs to track:

  • isAuthenticated - Is the user logged in?
  • user - User profile and roles
  • loading - Are we checking authentication?
  • error - What went wrong during login?
features/auth/authSlice.ts
interface AuthState {
isAuthenticated: boolean
user: User | null
token: string | null
loading: boolean
error: string | null
}
const authSlice = createSlice({
name: 'auth',
initialState: {
isAuthenticated: false,
user: null,
token: null,
loading: false,
error: null
} as AuthState,
reducers: {
loginStart: (state) => {
state.loading = true
state.error = null
},
loginSuccess: (state, action) => {
state.isAuthenticated = true
state.user = action.payload.user
state.token = action.payload.token
state.loading = false
},
loginFailure: (state, action) => {
state.loading = false
state.error = action.payload
},
logout: (state) => {
state.isAuthenticated = false
state.user = null
state.token = null
}
}
})

Handling App Initialization

This was the missing piece in my first implementation. When a user refreshes the page, I need to check if they have a valid session:

App.tsx
function App() {
const dispatch = useDispatch()
const { loading } = useAuth()
useEffect(() => {
const initAuth = async () => {
const token = getStoredToken()
if (token) {
try {
const user = await authAPI.getCurrentUser()
dispatch(loginSuccess({ user, token }))
} catch {
dispatch(logout())
}
}
}
initAuth()
}, [dispatch])
if (loading) {
return <AppLoadingScreen />
}
return <RouterProvider router={router} />
}

Without this check, authenticated users get redirected to login on every page refresh. That’s a terrible user experience.

Protecting Routes

Now that I have authentication state, I need to protect routes. The pattern I use is a wrapper component that checks authentication before rendering its children.

The ProtectedRoute Component

components/ProtectedRoute.tsx
interface ProtectedRouteProps {
children: React.ReactNode
requiredRoles?: string[]
}
function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
const { isAuthenticated, user, loading } = useAuth()
const location = useLocation()
if (loading) {
return <LoadingSpinner />
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
if (requiredRoles && !hasRequiredRole(user.roles, requiredRoles)) {
return <Navigate to="/unauthorized" replace />
}
return <>{children}</>
}

Using It in the Router

router.tsx
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute requiredRoles={['admin']}>
<AdminPanel />
</ProtectedRoute>
}
/>

The key insight: I check authentication state synchronously in the component, but I also redirect users back to where they came from after login. The location state captures the intended destination.

Implementing Role-Based Access Control

Authentication tells me who the user is. RBAC tells me what they can do.

Extracting Roles from JWT

The JWT payload should include user roles. After login, I decode the token to extract them:

const decoded = jwtDecode(response.token)
return {
token: response.token,
user: { ...response.user, roles: decoded.roles }
}

Role Checking Utilities

I created utility functions to keep role checks consistent:

utils/permissions.ts
export function hasRole(userRoles: string[], requiredRoles: string[]): boolean {
return requiredRoles.some(role => userRoles.includes(role))
}
export function hasPermission(
userPermissions: string[],
requiredPermissions: string[]
): boolean {
return requiredPermissions.every(perm =>
userPermissions.includes(perm)
)
}

Conditional UI Rendering

This is where RBAC becomes visible to users. Different roles see different UI elements:

components/Dashboard.tsx
function Dashboard() {
const { user, hasRole, hasPermission } = useAuth()
return (
<div className="dashboard">
<h1>Welcome, {user.name}</h1>
{hasRole(['admin']) && <AdminStats />}
{hasRole(['admin', 'manager']) && (
<TeamManagement />
)}
{hasPermission(['write:posts']) && (
<CreatePostButton />
)}
</div>
)
}

I also created a reusable component for role-based rendering:

components/RoleRestricted.tsx
function RoleRestricted({ roles, children, fallback = null }) {
const { user } = useAuth()
if (!hasRole(user.roles, roles)) {
return <>{fallback}</>
}
return <>{children}</>
}
// Usage
<RoleRestricted roles={['admin', 'editor']}>
<EditButton />
</RoleRestricted>
<RoleRestricted
roles={['premium']}
fallback={<UpgradePrompt />}
>
<PremiumFeatures />
</RoleRestricted>

The Most Important Lesson: Backend Validation

I spent weeks building the perfect frontend auth system. Then a security review pointed out a simple bypass: open DevTools, edit the Redux state to add admin role, and suddenly the UI shows admin features.

Frontend authentication and RBAC are for user experience, not security. The real security happens on the backend.

Every API call must validate:

  1. Is the token valid?
  2. Is the token expired?
  3. Does this user have permission for this operation?

The frontend checks are there to hide buttons and routes from users who shouldn’t see them. But the backend must enforce permissions on every request.

Common Mistakes I Made

Storing JWT in localStorage without XSS protection - This is a real vulnerability. Use httpOnly cookies if possible.

Not handling token expiration gracefully - Users would get stuck with broken sessions. Now I catch 401 errors and attempt a token refresh before logging out.

Frontend-only authorization - Easy to bypass. The backend must validate every request.

Missing loading states - Authentication checks are async. Without loading states, users see flickering UI or get incorrectly redirected.

Overcomplicating permissions too early - I started with a complex permission system. Should have started with simple roles and added granularity only when needed.

What Actually Made Me Job-Ready

Reading documentation and building toy apps taught me syntax. But building a real application with authentication, role-based access, and actual user management is where everything clicked.

As one experienced developer put it: “The biggest thing was building a real project with actual users… once I had to deal with auth, role-based access, real-time updates, and actual deployment issues… that’s when everything clicked.”

The combination of frontend auth flows, backend API integration, and state management creates the production-ready expertise employers need. Start with simple role-based checks, implement backend validation from the start, and add complexity only when your application requires it.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments