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.
| Store | Readable by JavaScript | Survives refresh | Risk |
|---|---|---|---|
localStorage | Yes | Yes | XSS can steal it |
sessionStorage | Yes | Per tab | Same, narrower |
| In memory | No | No | Lost on refresh |
HttpOnly cookie | No | Yes | Needs 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-side | What it actually does |
|---|---|
| Protected route | Stops a signed-out user seeing a blank page |
| Hidden button | Reduces clutter and confusion |
| Role check | Shows relevant options |
| Expiry check | Avoids 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 see | Cause | Fix |
|---|---|---|
| Token lost on refresh | Held only in memory | Persist it, and rehydrate on load |
| Redirect loop to login | The protected wrapper also wraps login | Exclude it |
| User appears logged in after expiry | Only checked presence, not exp | Decode and check |
| A hidden button's action still works | Hiding is not security | The API must enforce it |
| 401 handled per component | No central handling | Wrap 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
undefineddefault on the context, so misuse fails silently - No
useMemoon 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
replaceon an auth redirect - Different messages for unknown email and wrong password
- No
disabledon the sign-in button - Clearing
signingInonly 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.
- Build
AuthContextandAuthProviderwith theundefineddefault and the throw. - Use
useAuthoutside the provider. Confirm the clear error message. - Remove the
undefineddefault and repeat. Confirm the silent wrong behaviour. - Restore the session with the lazy initialiser. Sign in, refresh, and confirm you stay signed in. Remove it and confirm you do not.
- Remove
useMemofrom the context value. Use the Profiler to count consumer re-renders. - Build the login page with
fromhandling and confirm sign-in returns you to the attempted page. - Return "no such user" for an unknown email. Explain what that leaks, then unify the messages.
- Remove
disabled={signingIn}and double-click Sign in. - Clear
signingInonly on success, force a failure, and confirm the button stays disabled. - Attach the token to a third-party URL and confirm in the Network tab that your credential was sent.
- Expire the token manually and make a request. Confirm the redirect to login.
- Remove the login-endpoint exclusion and sign in with wrong credentials. Confirm the redirect loop.
- Add
RequireAuthandRequireRole. Sign in as a Teacher and try/fees. - Hide a delete button with
HasRole. Then call the delete endpoint directly with the Teacher's token — confirm the server rejects it. - Edit the token payload in DevTools to change your role. Confirm the UI changes and the API still refuses.
- 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
- Why give a context an
undefineddefault and throw in its hook? - Why does the context value need
useMemo? - Why exclude the login endpoint from 401 handling?
- Why is a protected route not a security control?
Next: State management