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

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:
authforge-client → github.com/hamidukarimi/authforge-client
authforge-express → github.com/hamidukarimi/authforge-express
If this saves you from rebuilding the same thing for the fifth time, drop a ⭐ on GitHub.