Skip to main content

Routing, JWT, and What Happens When a Session Expires

·1300 words·7 mins
Morten Jensen
Author
Morten Jensen
Former chef with over 20 years in professional kitchens, now studying computer science
MiseOS Development - This article is part of a series.
Part 13: This Article

Last week ended with working forms and a feature-based folder structure. This week was about connecting everything: routing, login, and what actually happens when a token expires mid-session.


Setting up React Router
#

React Router turns a single HTML page into something that feels like a multi-page application. The URL changes, the browser history works, and components mount and unmount based on the path — but the server never gets involved after the initial load.

The setup in App.jsx maps directly to the layout decisions from week one:

<Routes>
  <Route element={<PublicLayout />}>
    <Route index element={<PublicMenuPage />} />
    <Route path="/menu" element={<PublicMenuPage />} />
    <Route path="/takeaway" element={<PublicTakeawayPage />} />
  </Route>

  <Route element={<AuthLayout />}>
    <Route path="login" element={<LoginPage />} />
    <Route path="register" element={<RegisterPage />} />
  </Route>

  <Route element={<ProtectedRoute allowedRoles={['HEAD_CHEF', 'SOUS_CHEF']} />}>
    <Route element={<AdminLayout />}>
      <Route path="admin/dashboard" element={<DashboardPage />} />
    </Route>
  </Route>
</Routes>

Each layout wraps its pages using <Outlet /> — a placeholder React Router fills with the matched child route. This is what keeps the sidebar and topbar visible while only the content area changes when navigating between admin pages.

The important part is that navigation happens entirely client-side after the initial load: React Router swaps components based on the URL while keeping the layout mounted.


Protecting routes by role
#

ProtectedRoute sits between the router and the layout. It checks the current user before rendering anything:

const ProtectedRoute = ({ allowedRoles }) => {
  const { user } = useAuth();

  if (!user) return <Navigate to="/login" replace />;
  if (allowedRoles && !allowedRoles.includes(user.userRole))
    return <Navigate to="/forbidden" replace />;

  return <Outlet />;
};

No user → login. Wrong role → forbidden. Otherwise, render the child routes. The component doesn’t know anything about which layout lives below it — it just decides whether it should render.


Implementing login end to end
#

The login flow splits across three files, each with one responsibility.

authService.js — HTTP only. No state, no storage:

const login = async (credentials) => {
  const loginData = await apiClient('/auth/login', { method: 'POST', body: credentials });
  const fullProfile = await apiClient('/users/me', { token: loginData.token });
  return { ...fullProfile, token: loginData.token };
};

The second call to /users/me fetches the full profile immediately after login. The login endpoint only returns { token, email, role } — enough to navigate, but not enough to show a name in the TopBar. Since the token isn’t in localStorage yet at this point, apiClient accepts an optional token override for this one call.

AuthContext — state and persistence only. No API knowledge:

const login = (userData, token) => {
  localStorage.setItem('token', token);
  localStorage.setItem('user', JSON.stringify(userData));
  setUser(userData);
};

LoginPage — orchestrates the two and navigates based on role:

const data = await authService.login(credentials);
login(data, data.token);
navigate(roleRoutes[data.userRole] ?? '/');

Each piece is independently understandable. The service doesn’t know what happens after login. The context doesn’t know how the token was obtained. The page doesn’t know how tokens are stored.


Why localStorage — and what it actually protects
#

React state lives in memory. A page refresh resets everything to initial values. Without persistence, every F5 logs the user out even if their JWT is still valid.

localStorage survives the page lifecycle. On app startup, AuthContext reads back the stored token and validates it before trusting it:

const isTokenExpired = (token) => {
  if (!token) return true;
  try {
    const payload = JSON.parse(atob(token.split('.')[1]));
    return payload.exp * 1000 < Date.now();
  } catch {
    return true;
  }
};

const [token, setToken] = useState(() => {
  const t = localStorage.getItem('token');
  if (!t || isTokenExpired(t)) {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    return null;
  }
  return t;
});

If the token is expired on startup, it’s cleared immediately — the user won’t get a stale session. The () => syntax is a lazy initializer: React runs it once on mount, not on every render.

localStorage is not the security boundary. It handles the UX between valid sessions. The real enforcement happens on the backend — every API call sends the JWT and the server validates it.


Handling 401 and 403 — the hard part
#

JWT tokens expire. When they do mid-session, the next API call returns 401. The problem: apiClient is a plain JavaScript file outside React’s component tree — it has no access to useNavigate or AuthContext.

The solution was browser custom events. apiClient fires a signal, AuthContext listens for it:

// apiClient.js
if (response.status === 401 && !endpoint.includes('/auth/')) {
  window.dispatchEvent(new CustomEvent('auth:unauthorized', {
    detail: { message: 'Din session er udløbet. Log ind igen.' },
  }));
  const error = new Error('UNAUTHORIZED');
  error.statusCode = 401;
  throw error;
}

if (response.status === 403 && !endpoint.includes('/auth/')) {
  window.dispatchEvent(new CustomEvent('auth:forbidden'));
  const error = new Error('FORBIDDEN');
  error.statusCode = 403;
  throw error;
}
// AuthContext
useEffect(() => {
  const handleUnauthorized = (e) => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    setToken(null);
    setUser(null);
    setAuthMessage(e?.detail?.message ?? 'Din session er udløbet. Log ind igen.');
    navigate('/login');
  };

  const handleForbidden = () => navigate('/forbidden');

  window.addEventListener('auth:unauthorized', handleUnauthorized);
  window.addEventListener('auth:forbidden', handleForbidden);
  return () => {
    window.removeEventListener('auth:unauthorized', handleUnauthorized);
    window.removeEventListener('auth:forbidden', handleForbidden);
  };
}, [navigate]);

401 clears state and redirects to login with a message. 403 redirects to a forbidden page without logging out — the user is authenticated, they just don’t have access to that specific action.

The /auth/ check on both: a wrong password also returns 401, but that error belongs to the login form’s catch block, not a logout redirect.


Why AuthContext lives in src/context, not in features/auth
#

The first instinct was to put AuthContext inside features/auth — auth is what created it, so it belongs there.

The problem is that everything consumes it: ProtectedRoute, AdminLayout, KitchenLayout, any profile page. If AuthContext lived inside a feature, every other part of the app would have to import across feature boundaries. A component in components/routes would depend on features/auth — a shared infrastructure piece depending on a specific feature. That inverts the dependency direction.

Something consumed globally belongs in src/context. The feature creates the initial implementation, but it belongs to the whole app from the start.


Bugs along the way
#

CORS blocking the login request. The backend’s CORS config used allowHost with a hardcoded origin. When the preflight response came back without Access-Control-Allow-Origin, the browser blocked the request. Switching to reflectClientOrigin = true in Javalin’s CORS plugin fixed it — it reflects whatever origin the client sends rather than matching an exact string.

handleUnauthorized defined outside AuthProvider. The function was placed after the closing brace of the component, so it had no access to logout or setAuthMessage. It compiled without error but would have crashed at runtime the first time a token expired. Moving it inside the component gave it access to the state it needed.

useAuth() returning the context object, not the user. The first usage assigned the return value directly to a variable named user. But useAuth() returns { user, login, logout } — the whole context object. The fix was destructuring:

const { user } = useAuth();

Reflections
#

The 401 handling was the most interesting problem this week. The constraint — apiClient can’t use React hooks — forced a pattern I hadn’t used before: custom browser events as a communication channel between non-React code and React context. It’s not a pattern you’d reach for first, but it solves the problem cleanly without coupling the HTTP layer to React.

The separation between service, context, and page also held up under pressure. When the login response shape changed to include the full profile, the fix was entirely inside authService.js. Nothing else changed.


Next step
#

With routing and auth working, the first real feature can be built end to end. Next up: the dish suggestion workflow — kitchen staff proposing dishes, management approving or rejecting them, and all the edge cases that come with a feature that spans two user roles.


This is part 13 of the MiseOS development log. Follow along as I build a management tool for professional kitchens, one commit at a time.

MiseOS Development - This article is part of a series.
Part 13: This Article