Skip to main content
Published / updated

Authentication and Protected UI

Before you start

You need: API integration (Article 06).

Time: about 50 minutes, plus the practice.

Learning objective

Build a complete sign-in flow with shared auth state, and explain precisely which parts are security and which are interface.

Topics

  • The flow
  • Context for shared auth state
  • An auth provider
  • Token storage and its trade-off
  • Attaching tokens
  • Handling 401 centrally
  • Protected routes
  • Role-based UI
  • The security boundary

The flow

1. User submits credentials
2. POST /api/auth/login
3. API returns a JWT
4. Store the token, decode the user
5. Every request carries it
6. Protected routes redirect signed-out users
7. A 401 clears the token and redirects to login
8. Sign out clears everything

Context

Auth state is needed by the header, protected routes, role checks and the API client. Passing it through props means prop drilling through every level.

// features/auth/AuthContext.tsx
import { createContext, useContext, useState, useCallback, useMemo } from 'react';

export interface CurrentUser {
publicId: string;
name: string;
role: 'Admin' | 'Principal' | 'Teacher' | 'Staff' | 'Student';
schoolId: number;
}

interface AuthContextValue {
user: CurrentUser | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
hasRole: (...roles: string[]) => boolean;
}

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);

if (context === undefined) {
throw new Error('useAuth must be used inside an AuthProvider');
}

return context;
}

The undefined default plus the throw is the important pattern. Without it, a component used outside the provider silently receives a default object and behaves as if nobody is signed in — a confusing bug. The throw names the actual problem.

The provider

export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(() => readUserFromToken());

const login = useCallback(async (email: string, password: string) => {
const result = await apiRequest<TokenResult>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});

storeToken(result.accessToken);

const currentUser = readUserFromToken();

if (!currentUser) {
throw new Error('The token could not be read.');
}

setUser(currentUser);
}, []);

const logout = useCallback(() => {
clearToken();
setUser(null);
}, []);

const hasRole = useCallback(
(...roles: string[]) => user !== null && roles.includes(user.role),
[user]);

const value = useMemo(
() => ({ user, isAuthenticated: user !== null, login, logout, hasRole }),
[user, login, logout, hasRole]);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// main.tsx
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>

Three details:

The lazy initialiser () => readUserFromToken() restores the session on a page refresh. Without it, refreshing signs the user out.

useMemo on the value prevents a new object every render, which would re-render every consumer of the context.

useCallback on the functions keeps the memo's dependencies stable.

Token storage

// features/auth/token.ts
const TOKEN_KEY = 'authToken';

export function storeToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
// private mode or quota — the session lasts until reload
}
}

export function getToken(): string | null {
try {
const token = localStorage.getItem(TOKEN_KEY);

if (!token || isExpired(token)) {
clearToken();
return null;
}

return token;
} catch {
return null;
}
}

export function clearToken(): void {
try {
localStorage.removeItem(TOKEN_KEY);
} catch { /* ignore */ }
}

function decode(token: string): Record<string, unknown> | null {
try {
const [, payload] = token.split('.');
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return null;
}
}

function isExpired(token: string): boolean {
const payload = decode(token);
return !payload || (payload.exp as number) * 1000 < Date.now();
}

export function readUserFromToken(): CurrentUser | null {
const token = getToken();

if (!token) return null;

const payload = decode(token);

if (!payload) return null;

return {
publicId: payload.sub as string,
name: (payload.name as string) ?? '',
role: payload.role as CurrentUser['role'],
schoolId: Number(payload.SchoolId)
};
}

Every localStorage access is wrapped. Safari in private mode and a full quota both throw on setItem, and an unhandled throw during sign-in looks like a broken login.

Expiry is checked on every read. A token that expired hours ago is still in storage, and every call returns 401 — the application must clear it rather than loop.

StoreReadable by JavaScriptSurvives refreshRisk
localStorageYesYesXSS can steal it
sessionStorageYesPer tabSame, narrower
In memoryNoNoLost on refresh
HttpOnly cookieNoYesNeeds CSRF protection

A token in localStorage is readable by any script on the page, including a compromised third-party library. That is the standard XSS token-theft path.

An HttpOnly cookie cannot be read by JavaScript at all, which removes that risk and requires CSRF protection instead. Both are used in production; knowing the trade-off is the interview answer.

The mitigation is the same either way: never render untrusted data as HTML. React escapes by default; dangerouslySetInnerHTML is where the vulnerability comes from.

Short token lifetimes with a refresh token limit the damage — a stolen token expires in an hour rather than a week.

Attaching tokens

The client from the previous article already does this:

const token = getToken();

const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers
}
});

Attach it in one place. Setting the header in twenty call sites means twenty places to change and one that gets forgotten.

Only send it to your own API. baseUrl here guarantees that; a client that attaches the token to any URL hands your credential to third-party servers.

With Axios, an interceptor does the same:

api.interceptors.request.use(config => {
const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});

Handling 401 centrally

if (response.status === 401) {
clearToken();
window.location.href = '/login';
throw new ApiError(401, 'Session expired');
}

Handling it once means no component thinks about session expiry.

window.location.href is a full page reload, which clears all in-memory state — appropriate for an expired session. To preserve the return URL, use the router instead:

// Set by AuthProvider so non-component code can navigate
let redirectToLogin: (() => void) | null = null;

export function setLoginRedirect(fn: () => void) {
redirectToLogin = fn;
}
// In AuthProvider
const navigate = useNavigate();
const location = useLocation();

useEffect(() => {
setLoginRedirect(() => {
logout();
navigate('/login', { state: { from: location }, replace: true });
});
}, [logout, navigate, location]);

Exclude the login endpoint. A 401 from login means wrong credentials, and redirecting to login from login is an infinite loop.

Protected routes

export function RequireAuth({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const location = useLocation();

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

return <>{children}</>;
}

export function RequireRole({ roles, children }: { roles: string[]; children: React.ReactNode }) {
const { user } = useAuth();

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

if (!roles.includes(user.role)) {
return <Navigate to="/access-denied" replace />;
}

return <>{children}</>;
}
{
path: 'students',
element: <RequireAuth><Outlet /></RequireAuth>,
children: [ /* nested routes */ ]
},
{
path: 'fees',
element: <RequireRole roles={['Admin', 'Staff']}><Outlet /></RequireRole>,
children: [ /* nested routes */ ]
}

replace on the redirect means the back button does not return to a page the user cannot see.

The login page

export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();

const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [signingIn, setSigningIn] = useState(false);
const [error, setError] = useState<string | null>(null);

const from = (location.state as { from?: Location } | null)?.from?.pathname ?? '/students';

async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setError(null);
setSigningIn(true);

try {
await login(email, password);
navigate(from, { replace: true });
} catch (err) {
setError(err instanceof ApiError && err.status === 401
? 'Incorrect email or password.'
: 'Could not sign in. Please try again.');
} finally {
setSigningIn(false);
}
}

return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">Email</label>
<input id="email" type="email" value={email} autoComplete="username"
onChange={e => setEmail(e.target.value)} required />

<label htmlFor="password">Password</label>
<input id="password" type="password" value={password} autoComplete="current-password"
onChange={e => setPassword(e.target.value)} required />

{error && <p className="error" role="alert">{error}</p>}

<button type="submit" disabled={signingIn}>
{signingIn ? 'Signing in…' : 'Sign in'}
</button>
</form>
);
}

One message for both wrong email and wrong password. "No such user" tells an attacker which addresses are registered — the same rule as on the server.

autoComplete="username" and current-password let password managers work, which measurably improves password quality.

from sends the user back to what they were trying to reach.

Role-based UI

export function HasRole({ roles, children }: { roles: string[]; children: React.ReactNode }) {
const { hasRole } = useAuth();

return hasRole(...roles) ? <>{children}</> : null;
}
<HasRole roles={['Admin', 'Principal']}>
<button type="button" onClick={handleDelete}>Delete</button>
</HasRole>
const { user, hasRole, logout } = useAuth();

<header>
<span>{user?.name}</span>
{hasRole('Admin') && <Link to="/admin">Admin</Link>}
<button type="button" onClick={logout}>Sign out</button>
</header>

The security boundary

Everything in this article improves the interface. None of it is a security control.

Client-sideWhat it actually does
Protected routeStops a signed-out user seeing a blank page
Hidden buttonReduces clutter and confusion
Role checkShows relevant options
Expiry checkAvoids a pointless request

All of it is bypassable. RequireAuth can be skipped by calling the API directly. A hidden delete button still has an endpoint. The token's claims are readable and editable in DevTools — only the server validates the signature.

The server must enforce every rule independently:

[HttpDelete("{publicId:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid publicId, CancellationToken ct)
{
var schoolId = User.GetSchoolId(); // from the token, never the request

var deleted = await _studentService.DeactivateAsync(schoolId, publicId, ct);

return deleted ? NoContent() : NotFound();
}

A frontend that hides the delete button and an API that accepts a delete from anyone is an application with no access control at all.

Prove it to yourself: edit the token payload in DevTools to change your role. The UI changes; the API still refuses. That is the boundary.

Refresh tokens

let refreshPromise: Promise<string> | null = null;

async function refreshAccessToken(): Promise<string> {
if (refreshPromise) {
return refreshPromise; // reuse the in-flight refresh
}

refreshPromise = fetch(`${baseUrl}/api/auth/refresh`, {
method: 'POST',
credentials: 'include'
})
.then(async response => {
if (!response.ok) throw new ApiError(response.status, 'Refresh failed');

const result = (await response.json()) as TokenResult;
storeToken(result.accessToken);
return result.accessToken;
})
.finally(() => {
refreshPromise = null;
});

return refreshPromise;
}

The shared promise is the point. Without it, ten concurrent requests hitting an expired token trigger ten refresh calls, and nine fail because the first rotated the refresh token. Reusing the in-flight promise means one refresh and nine waiters.

The refresh token itself belongs in an HttpOnly cookie — credentials: 'include' sends it, and JavaScript never sees it.

Errors you will hit

What you seeCauseFix
Token lost on refreshHeld only in memoryPersist it, and rehydrate on load
Redirect loop to loginThe protected wrapper also wraps loginExclude it
User appears logged in after expiryOnly checked presence, not expDecode and check
A hidden button's action still worksHiding is not securityThe API must enforce it
401 handled per componentNo central handlingWrap fetch once

Hiding a button hides a door; it does not lock it. Authorisation is enforced by the API, and the frontend only avoids showing what will fail.

Common mistakes

  • No undefined default on the context, so misuse fails silently
  • No useMemo on the context value, re-rendering every consumer
  • Not restoring the session from the token on refresh
  • Never checking token expiry
  • No try/catch around localStorage
  • Attaching the token to third-party URLs
  • No login-endpoint exclusion, causing a 401 redirect loop
  • No replace on an auth redirect
  • Different messages for unknown email and wrong password
  • No disabled on the sign-in button
  • Clearing signingIn only on success
  • No refresh queue, triggering concurrent refreshes
  • Treating a protected route or hidden button as authorisation
  • Trusting decoded token claims for anything but display

Practice

The course assignment is add a basic authentication flow.

  1. Build AuthContext and AuthProvider with the undefined default and the throw.
  2. Use useAuth outside the provider. Confirm the clear error message.
  3. Remove the undefined default and repeat. Confirm the silent wrong behaviour.
  4. Restore the session with the lazy initialiser. Sign in, refresh, and confirm you stay signed in. Remove it and confirm you do not.
  5. Remove useMemo from the context value. Use the Profiler to count consumer re-renders.
  6. Build the login page with from handling and confirm sign-in returns you to the attempted page.
  7. Return "no such user" for an unknown email. Explain what that leaks, then unify the messages.
  8. Remove disabled={signingIn} and double-click Sign in.
  9. Clear signingIn only on success, force a failure, and confirm the button stays disabled.
  10. Attach the token to a third-party URL and confirm in the Network tab that your credential was sent.
  11. Expire the token manually and make a request. Confirm the redirect to login.
  12. Remove the login-endpoint exclusion and sign in with wrong credentials. Confirm the redirect loop.
  13. Add RequireAuth and RequireRole. Sign in as a Teacher and try /fees.
  14. Hide a delete button with HasRole. Then call the delete endpoint directly with the Teacher's token — confirm the server rejects it.
  15. Edit the token payload in DevTools to change your role. Confirm the UI changes and the API still refuses.
  16. Fire ten concurrent requests with an expired token and no refresh queue. Count the refresh calls, then add the shared promise.

Exercises 14 and 15 are the ones that make the security boundary concrete.

You can now

  • Build a complete authentication flow with shared state
  • Persist and rehydrate a token
  • Handle 401 centrally
  • Check token expiry
  • Say which parts of the UI are security and which are interface

Review questions

  1. Why give a context an undefined default and throw in its hook?
  2. Why does the context value need useMemo?
  3. Why exclude the login endpoint from 401 handling?
  4. Why is a protected route not a security control?

Next: State management