Without notes, state yesterday’s main idea and one unresolved question.
Login and session-aware UI
Connecting the Full Stack
Objective
Connect authentication while keeping security enforcement on the server.
This is system integration: individual modules may pass bench tests but the complete signal path must also be verified end to end.
- login flow
- credentialed requests
- session expiration
Why this matters
Your API has known who its users are since Week 8. Your frontend has not. Today they meet — and this is the boundary where beginners produce apps that look secure and are not. By the end a user will log in, reload the page and still be logged in, be sent away from pages they may not see, and get a clear message rather than a blank screen when their session ends.
One sentence to carry through the whole day: everything you do in the browser today is user experience, and none of it is security. The security was written on the server, and it stays there.
The login flow
A login is three endpoints, and it helps to see all three before writing any of them.
| Endpoint | Body in | What the server does |
|---|---|---|
POST /api/auth/login |
email, password | verify the password hash (Day 51), start a session, return the user |
GET /api/auth/me |
— | answer "who am I?" — the current user, or 401 |
POST /api/auth/logout |
— | end the session |
The one people leave out is GET /api/auth/me, and it is the one that makes reload work. When the
page reloads, every React state variable is destroyed. The frontend has no memory of who you are, so
on startup it asks the server, which does. That single request is the difference between "logged in
until you press F5" and a real session.
Two rules for the login response. It returns the user's id, name, and role — never the password hash, and never the password. And a failed login returns one generic message for both a wrong email and a wrong password: telling an attacker which half was right hands them a list of valid accounts.
Where the credential lives, and what it costs
After a successful login the browser must hold something that proves who you are on the next request. Where you put it is a real engineering decision with real consequences.
| Place | Survives reload | Readable by JavaScript on your page | Sent automatically |
|---|---|---|---|
| A JavaScript variable | no | yes | no |
sessionStorage |
per tab | yes | no |
localStorage |
yes | yes | no |
httpOnly cookie |
yes | no | yes |
The middle three share one weakness. If any script running on your page is hostile — a compromised
npm dependency, an injected <script>, a cross-site scripting hole (Day 53) — it can read the token
out of storage and send it anywhere. The token is then usable from the attacker's own machine, for
as long as it is valid.
An httpOnly cookie is set by the server with a flag that makes it invisible to JavaScript:
document.cookie does not contain it and no API can read it. The browser attaches it automatically
to requests to that origin. XSS on your page is still very bad — the injected script can make
requests as the user — but it cannot walk away with the credential.
The badge and the photocopier
A token in localStorage is a badge you hand to the receptionist yourself: convenient, and anyone
who gets near your desk can photocopy it and use it from across town. An httpOnly cookie is a
badge sealed inside the door reader — you can open doors with it, but you cannot take it out, and
neither can anyone standing next to you.
The cost is that "sent automatically" is also how CSRF works (Day 53): another site can cause
your browser to fire a request that carries the cookie. The defence is the SameSite attribute.
res.cookie('session', token, {
httpOnly: true, // JavaScript cannot read it
sameSite: 'lax', // not sent on cross-site requests from other sites
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
maxAge: 1000 * 60 * 60 * 8, // 8 hours
path: '/'
});
Interlock switch and contactor
A machine panel has an interlock that stops the operator pressing Start with the guard open, and a contactor that actually breaks the current. The interlock prevents mistakes; the contactor is what makes the machine safe. Route guards and hidden buttons are the interlock. Server-side permission checks are the contactor. Ship only the interlock and the machine is live behind a plastic cover.
Credentialed requests across origins
Your frontend is on http://localhost:5173 and your API on http://localhost:3000 — different
origins, as you learned on Day 64. Cookies are not attached to cross-origin fetch calls unless you
ask, and the server must agree separately:
// frontend
fetch(url, { credentials: 'include', ... });
// server
app.use(cors({ origin: 'http://localhost:5173', credentials: true }));
Both halves are required, and there is a third rule the browser enforces silently: when credentials
are involved, Access-Control-Allow-Origin may not be *. It must name one exact origin. This
is why yesterday's warning about the permissive setting mattered — it is not merely sloppy, it stops
working the moment you add auth.
Same-origin, same-site, cross-site
These are three different words. localhost:5173 and localhost:3000 are different origins
(the port differs) but the same site (the host is the same), so a SameSite=Lax cookie is still
sent between them. In production, app.example.com and api.example.com are also the same site.
Only a genuinely different domain is cross-site, and a cookie sent there needs
SameSite=None, which the browser accepts only together with Secure.
Session expiration
Sessions end: the cookie's maxAge passes, the token's expiry passes, or an administrator revokes
it. The important thing about expiry is when you find out. There is no event. You discover an
expired session on the next request that fails.
That means every screen must handle a 401 arriving at any moment, not just on the login page. If
you do not, an expired session shows up as a list that silently stops loading — the single most
confusing failure mode a user can hit, because nothing appears broken.
Handle it in one place, in the API helper, so no component has to remember:
if (response.status === 401) {
clearCurrentUser();
window.location.assign('/login?reason=expired');
throw new ApiError(401);
}
Then the login page reads reason=expired and says "Your session ended. Please sign in again."
instead of showing a bare form the user does not understand having been sent to.
Walkthrough
Hold the current user in one place — a React context, using the state and effect patterns from Days 59 and 61 — so that every component asks the same question and gets the same answer.
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<'checking' | 'ready'>('checking');
useEffect(() => {
apiFetch<User>('/api/auth/me')
.then(setUser)
.catch(() => setUser(null))
.finally(() => setStatus('ready'));
}, []);
async function login(email: string, password: string) {
const me = await apiFetch<User>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
setUser(me);
}
async function logout() {
await apiFetch('/api/auth/logout', { method: 'POST' });
setUser(null);
}
return (
<AuthContext.Provider value={{ user, status, login, logout }}>
{children}
</AuthContext.Provider>
);
}
The status field is not decoration. While the me request is in flight you do not yet know whether
the user is logged in, and a guard that treats "don't know" as "logged out" will bounce a legitimate
user to the login screen on every refresh — a bug you will otherwise chase for an hour.
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, status } = useAuth();
if (status === 'checking') return <p>Checking your session…</p>;
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
Navigate comes from the router you set up on Day 62; replace keeps the protected URL out of the
back-button history.
Two minutes that change how you think about guards
Log in as a technician, confirm the Delete button is hidden, then run the delete anyway from a
terminal:
curl -i -X DELETE http://localhost:3000/api/equipment/1 -b "session=<paste your cookie>"
You can copy the cookie from DevTools → Application → Cookies. If the server answers 403, your
Day 52 authorization is doing its job. If it answers 200, the hidden button was the only thing
protecting that row — and it protected nothing.
Reviewer mode — once login works
"Review this auth integration for token leakage, trusting route guards, and unclear session expiry behavior." Give it your auth context, your guard component, and your cookie options. Demand specific findings with evidence — the file, the line, and the request that demonstrates the problem. A review that returns praise has told you nothing.
Your turn
- Set the session cookie on successful login with
httpOnly,sameSite: 'lax', and amaxAge. Confirm in DevTools → Application → Cookies that HttpOnly is ticked. - Add
credentials: trueto yourcors()options andcredentials: 'include'toapiFetch. - Build the login page: email, password, pending state, and one generic error for any failure.
- Add
GET /api/auth/meand call it once on app startup. Model the three states — checking, signed in, signed out. - Wrap your equipment and maintenance routes in
RequireAuth. Confirm that visiting a protected URL directly while logged out sends you to/login. - Log in, then reload. You must stay logged in.
- Add the global
401handler. Test it by shorteningmaxAgeto 60 seconds, waiting, then clicking something. You should land on the login page with an explanation. - Implement logout with
res.clearCookieusing the same options you set it with, and confirm the cookie disappears from DevTools. - Run the
curlexperiment above and record the status code you got.
You are done when
You can log in, reload without losing the session, reach permitted pages, be redirected away from
forbidden ones, log out cleanly — and you can state, from your own curl output, that hiding a
control in the UI is not authorization and the server check is still required.
Common pitfalls
- Treating "still checking" as "logged out". The guard fires before
mereturns and kicks the user to/loginon every refresh. Three states, not two. - Forgetting one half of the credentials handshake.
credentials: 'include'withoutcredentials: trueon the server, or either one together withorigin: '*', fails — and the browser's console message names the header that is missing. Read it. - Storing the token in
localStoragebecause it was easier, and never revisiting it. If you choose it, write down why, and know that any script on your page can read it. - Clearing the cookie with different options than you set it with. A mismatched
pathleaves the cookie in place and the user apparently still logged in.
Verify it yourself
Open today's reference, the OWASP Top 10 for 2025, and find the category covering broken access control.
- Read its description and decide, honestly, whether an app whose only protection is a hidden button would be listed under it. Write down the sentence that settles it.
- Find the category covering authentication or session failures and note one recommendation it makes that your app does not yet follow. That is your first entry for Week 11's security day.
Add both to your project notes. Checking your own work against a published list — rather than against how confident you feel — is the habit this course is trying to leave you with.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Connect login/logout, protect routes for UX, and handle expired sessions.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
A user can log in, reload, access permitted pages, and log out.
Working with AI today
Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.
Review this auth integration for token leakage, trusting route guards, and unclear session expiry behavior.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.