0 / 91
Week 10 · Day 66 of 91

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

  1. Set the session cookie on successful login with httpOnly, sameSite: 'lax', and a maxAge. Confirm in DevTools → Application → Cookies that HttpOnly is ticked.
  2. Add credentials: true to your cors() options and credentials: 'include' to apiFetch.
  3. Build the login page: email, password, pending state, and one generic error for any failure.
  4. Add GET /api/auth/me and call it once on app startup. Model the three states — checking, signed in, signed out.
  5. Wrap your equipment and maintenance routes in RequireAuth. Confirm that visiting a protected URL directly while logged out sends you to /login.
  6. Log in, then reload. You must stay logged in.
  7. Add the global 401 handler. Test it by shortening maxAge to 60 seconds, waiting, then clicking something. You should land on the login page with an explanation.
  8. Implement logout with res.clearCookie using the same options you set it with, and confirm the cookie disappears from DevTools.
  9. Run the curl experiment 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 me returns and kicks the user to /login on every refresh. Three states, not two.
  • Forgetting one half of the credentials handshake. credentials: 'include' without credentials: true on the server, or either one together with origin: '*', fails — and the browser's console message names the header that is missing. Read it.
  • Storing the token in localStorage because 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 path leaves 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.

  1. 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.
  2. 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

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Connect login/logout, protect routes for UX, and handle expired sessions.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

A user can log in, reload, access permitted pages, and log out.

Working with AI today

AI as skeptical reviewer

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

Q1 Are hidden frontend controls sufficient authorization?
Q2 Which result best proves today’s work is complete?
Q3 What should an AI code review primarily produce?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.