Adding Authentication to React AppsEntry 9 of 11

Token Expiration and Automatic Logout

Tokens expire (often after one hour). The app stores the expiration time and logs the user out automatically when it's reached.

Notes

  • The backend sets the token's lifetime; the frontend stores a matching expiration timestamp (Entry 4).
  • getTokenDuration() returns the remaining milliseconds; getAuthToken() returns 'EXPIRED' when it's past.
  • In the root layout, a useEffect reads the token from the root loader:
    • No token: do nothing.
    • 'EXPIRED': submit to /logout immediately.
    • Valid: set a timer for the remaining duration, then submit to /logout.
  • useSubmit() triggers the logout action from code (see React Router, Entry 16).
  • Clear the timer in the effect's cleanup so logging out manually doesn't leave a stray timer.
  • Even with auto logout, the backend may reject a token early (e.g. revoked). Handle 401 responses by logging out too.

What / Why / When

What

Client-side expiry tracking.

Why

The UI shouldn't show a logged-in state with a token the backend will reject.

When

Any app using expiring tokens.

Code compare

React
// pages/Root.tsximport { useEffect } from "react";import { Outlet, useLoaderData, useSubmit } from "react-router";import MainNavigation from "../components/MainNavigation";import { getTokenDuration, type AuthToken } from "../util/auth";​export default function RootLayout() {  const token = useLoaderData() as AuthToken;  const submit = useSubmit();​  useEffect(() => {    if (!token) return;​    if (token === "EXPIRED") {      submit(null, { action: "/logout", method: "post" });      return;    }​    const timer = setTimeout(() => {      submit(null, { action: "/logout", method: "post" });    }, getTokenDuration());​    return () => clearTimeout(timer);  }, [token, submit]);​  return (    <>      <MainNavigation />      <main>        <Outlet />      </main>    </>  );}

The timer lives in AuthService (Entry 4): scheduleLogout() runs on startup for a stored token and after every login. To also log out on a 401 from the backend, extend the interceptor:

Angular
// interceptors/auth.interceptor.tsexport const authInterceptor: HttpInterceptorFn = (req, next) => {  const authService = inject(AuthService);  const token = authService.token();  const authReq = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;​  return next(authReq).pipe(    catchError((err: HttpErrorResponse) => {      if (err.status === 401 && token) authService.logout();      return throwError(() => err);    }),  );};

Differences and gotchas

  • React ties the timer to a component (RootLayout); Angular ties it to a singleton service, so it's independent of what's rendered.
  • setTimeout caps out around 24.8 days (2³¹ − 1 ms). Fine for hour-long tokens, but very long-lived tokens need a different approach.

Quick reference

TaskReactAngular
Store expirylocalStorage expirationSame, inside AuthService
Detect expired on loadgetAuthToken() === 'EXPIRED'readStoredToken() returns null
Auto logoutuseEffect + setTimeout + useSubmitscheduleLogout() in service
Logout on 401Check status in servicesInterceptor catchError