In my starter kit, the Sanctum token has exactly one name and exactly two modules that touch it. That was a deliberate decision, and it is the reason I can change my auth storage strategy in an afternoon instead of a week.
The name is saas_token. It lives in localStorage. Here is everyone who is allowed to know that.
Owner one: the axios client. Every request passes through a request interceptor that reads the key and attaches the header:
client.interceptors.request.use((config) => {
const token = localStorage.getItem('saas_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
And a response interceptor owns the "token died" path. On a 401, it clears the key and sends the user to login, unless they are already on the login or register page (redirecting someone off the login page because their non-existent token is invalid would be a special kind of bug):
client.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status;
const path = window.location.pathname;
if (status === 401 && path !== '/login' && path !== '/register') {
localStorage.removeItem('saas_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
Note this happens at the network layer, outside React. No component, no context, no state update involved. An expired token redirects you before React has an opinion about it. And the interceptor still rejects the promise, so a form that triggered the 401 can still show its error state if it wants to.
Owner two: the AuthContext. It holds the token in React state, initialised from the same key, and it is the only place that writes it:
const [token, setToken] = useState(() => localStorage.getItem('saas_token'));
const login = useCallback(async (email, password) => {
const res = await client.post('/auth/login', { email, password });
localStorage.setItem('saas_token', res.data.token);
setToken(res.data.token);
setUser(res.data.user);
return res.data;
}, []);
const logout = useCallback(async () => {
// Best-effort: the session is cleared locally no matter what.
try {
await client.post('/auth/logout');
} catch {
// Ignore — token is being discarded either way.
}
localStorage.removeItem('saas_token');
setToken(null);
setUser(null);
}, []);
Login and register write both localStorage and state in the same breath. Logout tries the server first (revoking the token server-side is polite) but clears locally regardless. The session dies on the client even if the server call fails, because a logout that depends on a healthy network is a logout that fails exactly when the user needs it most.
On mount, the context restores the session: read the key, call GET /auth/me, and if that fails, remove the key and settle into logged-out. One code path for "returning visitor" and "dead token".
Owner three: nobody. That is the point. No component reads localStorage directly. No page checks the token itself. Every component goes through useAuth(), which exposes exactly six things: user, token, loading, login, register, logout. And useAuth throws if you call it outside the provider, so forgetting the wrapper fails loudly in development instead of silently shipping undefined auth everywhere:
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
return ctx;
}
The payoff: if I ever move from localStorage to httpOnly cookies, the change surface is two files, client.js and AuthContext.jsx. Every consumer keeps working because they never knew where the token lived. Ownership is a contract: one name, two modules, and a public API of six values. Everything else just asks.
This is the auth layer of the starter kit I maintain and sell. The full thing, with the Sanctum backend and this exact frontend, is here: Laravel + React SaaS Starter Kit
Top comments (0)