One Token, One Flow#
When a user logs in, the backend returns a JWT. Everything in the auth layer follows from that single token — how it is stored, decoded, and distributed to the components that need it. Getting that pipeline right took more thought than I initially expected, and a few of the decisions along the way are worth documenting.
The Token#
The token is stored in localStorage and read by a shared apiClient on every authenticated request. The rest of the application never handles it directly.
The payload is Base64-encoded, not encrypted — it can be decoded client-side without a secret:
function decodeToken(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return { id: payload.id, username: payload.sub };
} catch {
return null;
}
}The catch branch treats any malformed or tampered token as unauthenticated rather than crashing. The decoded result — id and username — is all the frontend needs to identify the current user. Sensitive data does not belong in a JWT payload, and nothing sensitive is stored there.
From Token to State#
AuthProvider initialises from storage on mount using a lazy initialiser. If a valid, unexpired token exists, the user is restored immediately without a login round trip. If the token is missing or expired, it is cleared and state starts as null. From that point, currentUser is the source of truth. The token stays in storage; the application works from state.
Rather than scattering auth checks across individual pages, I centralised the logic in ProtectedRoute — one component that handles the redirect, the message, and the destination forwarding for every protected route in the application.
Down the Tree#
Auth state is split across two contexts. AuthContext exposes currentUser and isLoggedIn. AuthActionsContext exposes login and logout. A component that only needs to know whether the user is logged in does not receive the actions, and vice versa.
Both contexts are consumed through useAuth.js, which exports useAuth and useAuthActions as named hooks. Components never import from the context file directly — the hooks are the public interface. This keeps the internals of the context structure isolated behind a stable API. If the implementation changes, the consuming components do not need to.
Splitting into two contexts was a decision made for separation of concerns rather than necessity — a single context would have worked. It made consuming components cleaner and gave a clearer boundary between reading state and triggering side effects.
The Login Page#
The login page is the entry point of the flow for unauthenticated users. When ProtectedRoute redirects an unauthenticated user, it forwards the current path and a status message as navigation state. The login page reads that state on mount and surfaces it through StatusMessage, so the user understands why they ended up there.
After a successful login the user should be sent back to where they came from. In practice, this turned out to be harder than expected. Several attempts at reading the destination from navigation state and redirecting after login produced race conditions — the user would land on the homepage instead of their destination, or receive an error message despite a successful login. The current implementation captures isLoggedIn as a snapshot on mount rather than reading live state during the redirect, which resolved the most visible symptoms. It is not a clean solution, and it is a known limitation.
A failed login attempt clears the password field and shows a generic error message. The message does not indicate whether the email or password was wrong — a deliberate choice that mirrors the backend’s approach to avoiding credential enumeration.
What I Would Do Differently#
The redirect race condition is the most obvious candidate for a revisit. React Router’s data router API should handle this axact problem and is worth looking in to.
The other thing I considered but ruled out of scope was a “remember me” option — letting the user choose whether the session should persist across browser closes. The infrastructure for it is already there; it would be a matter of choosing between localStorage and sessionStorage based on the user’s preference at login time. It remains an interesting future addition.
