Authentication Fundamentals: Login, Sessions, and Secure Logout
Understand the difference between authentication and authorization, how login and logout flows work with sessions and tokens, the security trade-offs of different token storage approaches (always favoring secure storage over convenience), and how to build protected routes with proper session-expiration and CSRF/XSS awareness.
Authentication answers one question: who are you? It's the process of proving identity — typically with a password, a passkey, or a federated login — and it's the foundation everything else in a secure app is built on. Getting authentication right in a React app is less about React itself and more about correctly handling credentials the browser gives you: where you store a token, how you prove that token on each request, and how you make sure logout actually revokes access rather than just clearing local state.
Authentication vs. authorization
Authentication (AuthN) establishes identity: the server confirms you are who you claim to be, usually via a login flow, and issues some proof of that (a session or a token) that you present on subsequent requests. Authorization (AuthZ) is a separate, later question: given that we know who you are, are you allowed to do this specific thing? A logged-in user is authenticated, but that says nothing about whether they're allowed to delete another user's post.
This lesson focuses on authentication — proving and maintaining identity. The companion 'Authorization' module covers what a verified identity is and isn't permitted to do, including the critical point that any authorization check performed only in the frontend is a UX convenience, not a security boundary.
Login, sessions, and tokens
A login flow sends credentials (or completes a passwordless/OAuth challenge) to the server, which verifies them and then issues proof of authentication for future requests — this proof is either a server-side session or a client-held token.
A cookie-based session works by having the server generate a random session identifier, store the corresponding user data server-side, and send the identifier to the browser as a cookie. Critically, that cookie should be marked HttpOnly (inaccessible to JavaScript), Secure (sent only over HTTPS), and SameSite (restricting cross-site sending) — the browser then automatically attaches it to every request to your domain, and the server looks up the session by that identifier.
A token-based approach, common for APIs consumed by multiple client types (web, mobile, third-party), instead issues a signed access token (commonly a JWT) that encodes the user's identity and is verified by its signature rather than a server-side lookup. Access tokens are deliberately short-lived (often minutes), paired with a longer-lived refresh token used to obtain a new access token without forcing the user to log in again — this limits how long a stolen access token remains useful.
Token storage trade-offs — always favor security over convenience
Where you store a token is one of the highest-stakes decisions in frontend auth, and it should never default to whatever is most convenient to code. localStorage and sessionStorage are readable by any JavaScript running on the page — including a malicious script injected via a cross-site scripting (XSS) vulnerability in any dependency, ad, or unsanitized user content anywhere on the site. A token in localStorage is one XSS bug away from being exfiltrated wholesale.
An HttpOnly cookie cannot be read by JavaScript at all, which removes the XSS-theft vector entirely — this is the recommended default for storing the primary session/refresh credential in a first-party web app. The trade-off is that because the browser sends HttpOnly cookies automatically with every matching request, you become responsible for CSRF protection (covered below), since a cookie alone doesn't prove the request was intentionally initiated by your page.
The correct default posture, and the only one this lesson endorses, is: store the long-lived credential (session id or refresh token) in an HttpOnly, Secure, SameSite cookie, keep short-lived access tokens in memory (a JS variable/React state, never persisted storage) where practical, and add explicit CSRF protection alongside the cookie. Storing tokens in localStorage for the sake of implementation simplicity is a shortcut that trades away real security and should not be taught or used as a default.
Protected routes, session expiration, and secure logout
A protected route checks authentication state before rendering its children, redirecting to a login page if the user isn't authenticated — implemented as a wrapper/layout component that reads auth state from context and either renders the page or issues a redirect. It must explicitly account for the loading state of the initial auth check, or authenticated users see a flash of the login page before their session is confirmed.
Session expiration means a session or token is only valid for a bounded time — enforced server-side, not just assumed client-side. When an access token expires, the client attempts a silent refresh using the refresh token; if the refresh token itself has expired or been revoked, the user is redirected to log in again. Server-enforced expiration matters because a client can't be trusted to 'agree' to expire its own credentials.
Cross-Site Request Forgery (CSRF) exploits the browser's automatic cookie-sending behavior: a malicious site can trigger a request to your API, and if you rely purely on a cookie to authenticate, that forged request looks legitimate. Defenses include a CSRF token (a value the server issues and the client must echo back in a header, which a third-party site can't read) and, where supported, SameSite=Strict/Lax cookies, which prevent the cookie from being sent on cross-site requests in the first place.
Secure logout means actually invalidating the credential server-side — deleting the session record or revoking/blacklisting the refresh token — not merely clearing it from client storage. Clearing a cookie or a React state variable on the client does nothing to a still-valid token an attacker may have already captured; only server-side revocation actually ends the session.
Auth context and a protected route wrapper
credentials: 'include' ensures the HttpOnly session cookie is sent with the request; no token is ever read or stored in JavaScript-accessible memory. isLoading is checked before the redirect logic to avoid a flash-redirect for users who are actually authenticated but whose initial check hasn't resolved yet.
type AuthState = { user: { id: string; email: string } | null; isLoading: boolean };
const AuthContext = createContext<AuthState>({ user: null, isLoading: true });
function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({ user: null, isLoading: true });
useEffect(() => {
// relies on an HttpOnly session cookie sent automatically; no token read from JS
fetch('/api/me', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : null))
.then((user) => setState({ user, isLoading: false }))
.catch(() => setState({ user: null, isLoading: false }));
}, []);
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useContext(AuthContext);
if (isLoading) return <Spinner />;
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}Code Examples
The client never reads or writes the session token directly — it exists only as an HttpOnly cookie the browser manages. Logout calls a server endpoint that revokes the session record, so the credential can't be reused even if it were somehow captured. Expected result: After logout, the /api/me check on the next page load returns unauthenticated, even if an old cookie value were replayed.
await fetch('/api/logout', ...) — logout must hit the server to revoke the credential, not just clear client state.
The short-lived access token lives only in a module-level JS variable (not localStorage), so it disappears on page reload by design; the refresh token stays in an HttpOnly cookie the client never touches directly, and is what silently obtains a fresh access token. Expected result: A request made after the access token has expired transparently triggers a refresh and retries, so the user experiences no interruption unless the refresh token itself is also expired.
async function login(email: string, password: string) {
const res = await fetch('/api/login', {
method: 'POST',
credentials: 'include', // browser stores the HttpOnly session cookie the server sets
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Invalid credentials');
return res.json(); // { user }
}
async function logout() {
// server deletes/revokes the session; the cookie is cleared as a response header
await fetch('/api/logout', { method: 'POST', credentials: 'include' });
// client-side state is cleared too, but the server-side revocation is what actually matters
window.location.href = '/login';
}let accessToken: string | null = null; // held in memory only, never persisted
async function apiFetch(url: string, options: RequestInit = {}) {
let res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
});
if (res.status === 401) {
// access token expired; use the HttpOnly refresh cookie to get a new one
const refreshRes = await fetch('/api/refresh', { method: 'POST', credentials: 'include' });
if (!refreshRes.ok) {
window.location.href = '/login'; // refresh token invalid/expired too
throw new Error('Session expired');
}
const { accessToken: newToken } = await refreshRes.json();
accessToken = newToken;
res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
});
}
return res;
}Auth check flow for a protected route
Checking isLoading before the 'no user' branch avoids a flash-redirect to /login for users whose session is actually valid but still being verified.
Visit protected route
|
isLoading? -> show spinner
|
user present? -> render page
|
no user -> redirect to /loginImportant Rules
Never default to localStorage/sessionStorage for a session or refresh token — prefer an HttpOnly, Secure, SameSite cookie for any long-lived credential; this is the baseline, not an advanced option.
Always enforce session/token expiration server-side; client-side expiration checks are a UX nicety, not a security control.
Always add explicit CSRF protection (a CSRF token and/or SameSite cookies) whenever authentication relies on a cookie the browser sends automatically.
Logout must revoke the credential server-side (delete the session, blacklist/rotate the refresh token) — clearing client-side state alone does not end a session.
Handle the auth-loading state explicitly in every protected route; treat 'unknown yet' as a distinct state from 'authenticated' and 'unauthenticated.'
Where this shows up
Every account-gated product — banking apps, dashboards, email clients — depends on getting this right: an HttpOnly-cookie-based session or refresh token, CSRF protection alongside it, server-enforced expiration, and a logout that actually revokes access are the baseline expected by any security review, not advanced hardening.
// storing the primary session token for convenience localStorage.setItem('token', accessToken); // any injected script on the page can now read it: localStorage.getItem('token')
// server sets an HttpOnly cookie on login; the client never touches the token directly fetch('/api/login', { method: 'POST', credentials: 'include', body: ... }); // short-lived access tokens, if used, are kept in memory only, not persisted storage
A token in localStorage is readable by any JavaScript running on the page, so a single XSS vulnerability anywhere in the app (a dependency, unsanitized content, a compromised script) can exfiltrate it wholesale; an HttpOnly cookie is never exposed to JavaScript at all.
async function logout() { localStorage.removeItem('token'); // client believes it's logged out window.location.href = '/login'; } // the token, if it was a long-lived JWT, remains valid and usable if captured async function logout() { await fetch('/api/logout', { method: 'POST', credentials: 'include' }); // server revokes it window.location.href = '/login'; }
Clearing client-side storage doesn't invalidate a token or session on the server — if an attacker had already captured the credential, it would remain fully usable after this 'logout' unless the server explicitly revokes it.
[multiple-choice] What is the key difference between authentication and authorization? A. They are two names for the same process B. Authentication proves who you are; authorization determines what you're allowed to do once identified C. Authentication only applies to mobile apps D. Authorization always happens before authentication
B. Authentication proves who you are; authorization determines what you're allowed to do once identified — Authentication establishes identity (login), while authorization is the separate, later check of whether that identified user is permitted to perform a specific action or access a specific resource.
[multiple-choice] Why is an HttpOnly cookie recommended over localStorage as the default place to store a session credential? A. HttpOnly cookies are faster to read than localStorage B. localStorage has a smaller storage limit C. HttpOnly cookies are inaccessible to JavaScript, which removes the XSS token-theft vector that localStorage is exposed to D. HttpOnly cookies never expire
C. HttpOnly cookies are inaccessible to JavaScript, which removes the XSS token-theft vector that localStorage is exposed to — Because HttpOnly cookies cannot be read by any JavaScript running on the page, a cross-site scripting vulnerability elsewhere in the app cannot be used to steal the credential — unlike a token sitting in localStorage, which any script can read.
[multiple-choice] Why does a secure logout need to call a server endpoint rather than just clearing client-side storage? A. It doesn't — clearing client storage is sufficient B. Clearing client state doesn't revoke the credential server-side, so a captured token or active session would remain valid and usable C. Server endpoints are required by the browser's cookie API D. It's only necessary for token-based auth, never for cookie-based sessions
B. Clearing client state doesn't revoke the credential server-side, so a captured token or active session would remain valid and usable — The credential (session record or refresh token) actually lives on the server; only a server-side revocation invalidates it, so a logout that only clears local state leaves any already-captured credential fully usable.
Extend the ProtectedRoute component so that if the /api/me check returns a 401, it also attempts a token refresh once before redirecting to /login, so a page reload doesn't force a re-login when only the access token (not the refresh token) has expired.
useEffect(() => { async function checkAuth() { let res = await fetch('/api/me', { credentials: 'include' }); if (res.status === 401) { const refreshed = await fetch('/api/refresh', { method: 'POST', credentials: 'include' }); if (refreshed.ok) { res = await fetch('/api/me', { credentials: 'include' }); } } setState({ user: res.ok ? await res.json() : null, isLoading: false }); } checkAuth(); }, []);
Attempt the refresh inside the same effect that checks auth state, before deciding isLoading is false, and only redirect if the refresh also fails.