Skip to main content

Command Palette

Search for a command to run...

I Rebuilt Auth From Scratch, Here's Everything I Got Wrong the First Time

Updated
6 min read
I Rebuilt Auth From Scratch, Here's Everything I Got Wrong the First Time
H
I am a full-stack web and mobile app developer and self-taught. my main goal of here is to learn something new, discuss with programmers, and solve the problem.

I Rebuilt Auth From Scratch, Here's Everything I Got Wrong the First Time

I've built authentication more times than I can count. Every new project, same boilerplate. Login form, token in localStorage, a context wrapping the app, a couple of protected routes, done. Ship it.

Then I actually thought about what I was shipping.

  • Tokens sitting in localStorage, exposed to any XSS attack

  • No silent refresh, users getting logged out mid-session

  • Race conditions when multiple requests expired simultaneously

  • Error messages that said "Request failed with status code 400"

  • Page refreshes redirecting authenticated users to login

So I started over. Built it properly. Open-sourced it as authforge-client.

This is everything I fixed and why.


The stack

Before diving into the decisions, here's what's under the hood:

Layer Choice
Framework React + TypeScript (Vite + SWC)
Server state TanStack Query
Client state Zustand
Validation Zod
Forms React Hook Form
HTTP Axios
Animation Framer Motion
Styling Tailwind CSS v4

The folder structure is feature-based:

src/
├─ api/           # axios instance + endpoints
├─ features/
│  ├─ auth/       # login, register, schemas, hooks, services
│  └─ user/       # change password
├─ components/
│  ├─ ui/         # Button, Input, Alert, Spinner
│  └─ layout/     # Navbar, ProtectedRoute, PublicRoute
├─ store/         # Zustand auth store
├─ context/       # AuthContext (session restore)
├─ hooks/         # useAuth, useLogout
└─ utils/         # token helpers, error handler

Each feature owns its components, hooks, services, schemas, and types. New features never touch existing ones.


Mistake #1: localStorage

The first thing I changed was where the access token lives.

localStorage persists forever across tabs and sessions. Any JavaScript running on the page, including injected scripts from an XSS attack, can read it. That's a real risk.

sessionStorage is better. It's cleared when the tab closes. It's still readable by JavaScript, but the blast radius of a compromise is smaller, the token expires quickly anyway.

The refresh token is a different story. It lives in an httpOnly cookie. JavaScript cannot touch it at all. No XSS attack can steal it. The browser attaches it automatically to requests with withCredentials: true.

// Access token — short lived, sessionStorage
export const setToken = (token: string) =>
  sessionStorage.setItem("access_token", token);

// Refresh token — httpOnly cookie, set by backend only
// Frontend never touches this directly

Mistake #2: No silent token refresh

The old approach: access token expires → user gets logged out → user is confused.

The correct approach: access token expires → silently get a new one → user never notices.

The Axios response interceptor handles this:

api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const original = error.config as RetryableRequest;

    if (error.response?.status === 401 && !original._retry) {
      original._retry = true;

      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });

        if (!isRefreshing) {
          isRefreshing = true;
          api.post("/api/token")
            .then(({ data }) => {
              setToken(data.data.accessToken);
              processQueue(null, data.data.accessToken);
            })
            .catch((err) => {
              processQueue(err, null);
              window.dispatchEvent(new Event("auth:logout"));
            })
            .finally(() => { isRefreshing = false; });
        }
      });
    }

    return Promise.reject(error);
  }
);

The failed queue is the critical detail. Without it, three simultaneous expired requests trigger three simultaneous refresh calls, a race condition that can log the user out entirely. With the queue, one refresh happens, and all three requests retry with the new token.


Mistake #3: The page refresh redirect bug

When the page refreshes, Zustand resets. isAuthenticated is false. The ProtectedRoute needs to check the session before deciding whether to redirect.

My first attempt used useRef:

const hasChecked = useRef(false);

useEffect(() => {
  if (hasChecked.current) return;
  hasChecked.current = true;
  void restoreSession();
}, [restoreSession]);

Two problems:

Problem 1: useRef resets when the component unmounts. ProtectedRoute unmounts on every route change, so restoreSession fired on every navigation between protected pages.

Problem 2: isInitializing starts as false. On the very first render, before the useEffect fires, the component sees isAuthenticated: false and isInitializing: false, and redirects to /login immediately, before the session check even runs.

The fix for both:

// Module-level — persists across the entire app session
let sessionChecked = false;

const ProtectedRoute = () => {
  const { isAuthenticated, isInitializing, restoreSession } = useAuth();

  useEffect(() => {
    if (sessionChecked) return;
    sessionChecked = true;
    void restoreSession();
  }, [restoreSession]);

  // Before useEffect fires, sessionChecked is still false
  // Show spinner — never redirect yet
  if (!sessionChecked || isInitializing) {
    return <Spinner />;
  }

  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  return <Outlet />;
};

A module-level variable lives outside the React component lifecycle entirely. It stays true for the full app session regardless of how many times the component mounts and unmounts.


Mistake #4: Raw error messages in the UI

TanStack Query stores whatever is thrown from mutationFn. If you throw a raw AxiosError, that's what gets stored, and error.message becomes "Request failed with status code 400."

The fix is to parse the error inside mutationFn before throwing:

mutationFn: async (payload: RegisterPayload) => {
  try {
    const result = await registerService(payload);
    setToken(result.accessToken);
    setUser(result.user);
  } catch (rawError) {
    throw parseApiError(rawError);
  }
}

parseApiError extracts error.response.data.message from the Axios error, so TanStack Query stores a ParsedError with a proper message. The UI shows "Email already registered" instead of a status code.


Mistake #5: Cross-field validation timing

The registration form has two steps. Step 1: name, email, password, confirm password. Step 2: username, birthday, gender, location.

Clicking Next should validate Step 1 fields, including checking that passwords match, before allowing the user to proceed.

My first approach used Zod's .superRefine() for the cross-field check:

.superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Passwords do not match",
      path: ["confirmPassword"],
    });
  }
});

The problem: trigger(["confirmPassword"]) only runs field-level validation. Root-level schema refinements don't run. So clicking Next with mismatched passwords passed validation and moved to Step 2.

The fix — check manually in the navigation handler:

const goNext = async () => {
  const valid = await trigger([
    "firstname", "lastname", "email",
    "password", "confirmPassword"
  ]);

  if (!valid) return;

  const { password, confirmPassword } = getValues();
  if (password !== confirmPassword) {
    setError("confirmPassword", {
      type: "manual",
      message: "Passwords do not match",
    });
    return;
  }

  setStep(2);
};

setError attaches the error directly to the field and shows it inline immediately. The Zod refinement still runs as a safety net on final submission.


The result

A starter that handles the hard parts of auth correctly:

✅ Secure token storage ✅ Silent token refresh with failed queue pattern ✅ Session restore on page refresh without false redirects ✅ Human-readable error messages ✅ Cross-field form validation at the right time ✅ Feature-based architecture that scales

Both the frontend and backend are open source:

If this saves you from rebuilding the same thing for the fifth time, drop a ⭐ on GitHub.