Sessions & Cookies
Learning Objectives
- You can derive browser account state from a session response.
- You can explain cookie scope, session expiry and server-side revocation.
- You can trace the browser cookie and server session through login, reload and logout.
The user has signed in to the notes application. When they reload the page or edit another note, we do not want to ask for the password again. Let’s follow how the next request reaches the same account, and how the browser can find out whether that session is still valid.
A session lets requests use an authenticated identity without resending the password. The account API creates an opaque token, a value the browser does not interpret, and places it in a cookie. On later requests, the server looks up the corresponding session and gives resource handlers a safe user containing only id and displayName.
The browser carries the cookie
Start with the response to a successful login. Over HTTPS, it can set this cookie; the token text is a placeholder:
Set-Cookie: app_session=opaque-token-placeholder; Path=/; HttpOnly; SameSite=Lax; SecureWith no Domain attribute, the cookie belongs to the host in the browser-visible response URL. Path=/ allows it on matching requests throughout that host. The browser stores the cookie from Set-Cookie and adds a Cookie header on later requests when its rules allow. HttpOnly prevents page JavaScript from reading the cookie; it does not stop the browser from sending it. Secure limits sending to secure connections.
SameSite=Lax restricts cookie sending in cross-site contexts. For this attribute, a site combines the scheme with the registrable domain, the domain an individual or organization can register. For example, https://app.example.test and https://preview.example.test share the site https://example.test, but have different origins because their hosts differ. Within the same registrable domain and scheme, changing a subdomain or port changes the origin but not the site. Cookie restrictions do not replace the server’s permission checks.
After a reload, the notes page can send GET /api/session to rediscover the account. That response returns a safe account view. Application code does not need to read the session cookie or copy its token into localStorage or JSON bodies.
Browser JavaScript cannot read the Set-Cookie response header or set the Cookie and Origin request headers; the browser controls them. A direct API test runs outside the browser and can read or construct these headers itself. Such a test does not verify browser cookie rules. A browser login, reload and logout flow checks cookie storage and sending in that environment.
Which check needs a browser?
0 / 5 points
A direct Request test manually attaches a Cookie header. Which additional property does a real login-reload-logout browser flow examine?
Origins and local development
An origin includes scheme, host and port. http://localhost:5173 and http://localhost:8000 are different origins, although cookies are not isolated by port. With separate UI and API origins, use credentials: "include" on the login request and later API requests. This lets the browser accept the login response’s cookie and send matching stored cookies; it does not set a cookie value itself. For the allowed UI origin, the API returns Access-Control-Allow-Origin: http://localhost:5173 and Access-Control-Allow-Credentials: true; a wildcard origin cannot be used for this credentialed response. Cookie scope and SameSite rules still apply.
With the UI at http://localhost:5173 and API at http://localhost:8000, trace these two browser requests. The first logs in; the second reads the account fields without sending the password again:
const apiBase = "http://localhost:8000";
async function logInAndReadAccount(email: string, password: string) { const login = await fetch(`${apiBase}/api/auth/login`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); if (login.status !== 200) { throw new Error("Login failed"); }
// The browser has handled Set-Cookie; JavaScript does not read its value. const session = await fetch(`${apiBase}/api/session`, { credentials: "include", }); if (session.status !== 200) { throw new Error("Could not read the session"); } const { user } = await session.json(); return { user };}The second request relies on the browser to add its stored cookie. The function returns only the account fields from the session response; neither request constructs a Cookie header. This is the same division of work we need when the notes page loads after a successful login.
With one browser-visible origin, fetch("/api/session") sends same-origin credentials by default. A server or proxy can route pages to the UI and /api/... to the API. The API still checks application permissions.
For this local HTTP example, the server uses a cookie without Secure. Use Secure with HTTPS. Local browser tests establish behavior for their cookie and origin settings, not every deployment.
Localhost ports and cookies
0 / 5 points
The UI uses localhost:5173 and API localhost:8000. Which statement is accurate?
Session lifetime and logout
So far, the cookie has let the next request find the account. It does not make the session valid forever. Compare what the browser and server keep as the user reloads, the session expires, or the user logs out:
| Event | Browser | Server | Result on the next protected request |
|---|---|---|---|
| Successful login | Stores the session cookie. | Creates a session referring to an account. | Resolves that account while the session remains valid. |
| Page navigation or reload | Keeps and sends the cookie within its scope. | Keeps the session independently of the page. | The new page can rediscover the account through /api/session. |
| Session expiry | May still hold a cookie until its own expiry/removal. | Treats the session as expired. | Rejects the protected request; a displayed name cannot extend the session. |
| Logout | Receives an expired cookie. | Invalidates the session. | Neither this browser nor another caller with a copied token can use that session. |
Sessions have a server-defined lifetime, such as 24 hours. A protected request is rejected if its session has expired or been revoked, or if its account is disabled or deleted.
When the server accepts POST /api/auth/logout, it invalidates the session, expires the browser cookie with matching name, path and domain scope, and returns an empty 204 response. Clearing only local state would leave a copied credential usable. If the session is already absent or expired, an accepted logout still clears the cookie and returns 204, so repeating logout is safe. Chapter 5.6 introduces the request checks that precede these changes.
Figure 1 traces cookie creation, session reads after reload, and revocation at logout. It shows the session changes for accepted requests and the account fields selected from their responses.
Logout with a copied token
0 / 5 points
Logout expires the browser cookie but leaves the server session row valid. A raw client retained the old token. What is missing?
Represent what the client knows
The server checks the session on each protected request, but the page also needs to decide what to display. On a reload, we cannot yet assume the user is signed in or anonymous. The account view starts unknown while GET /api/session runs. These excerpts select the account fields from its two successful responses:
{ "user": { "id": 7, "displayName": "Example user" }}{ "user": null}A 200 response with user: null establishes anonymous state; one with a user establishes authenticated state. A network failure or unsuccessful response establishes neither. The model receives readSession as an argument: this HTTP adapter sends GET /api/session, checks the status, and returns {user} selected from the parsed response body or rejects on failure.
type SafeUser = { id: number; displayName: string;};type SessionResponse = { user: SafeUser } | { user: null };type AccountState = | { kind: "unknown"; message?: string } | { kind: "anonymous" } | { kind: "authenticated"; user: SafeUser };
let account: AccountState = { kind: "unknown" };
async function refreshAccount(readSession: () => Promise<SessionResponse>) { account = { kind: "unknown" }; try { const result = await readSession(); account = result.user === null ? { kind: "anonymous" } : { kind: "authenticated", user: result.user }; } catch { account = { kind: "unknown", message: "Could not check your account. Retry." }; }}This model handles one session read at a time. While state is unknown, show loading if no message is set, or the message and a retry control after failure. Show a sign-in link for anonymous and the name for authenticated. Share this state between the header and page components.
The selected response type is written out to focus on account state. Hono RPC can infer the full response type from the server’s route declarations, but the client must still check the HTTP status and handle failures.
Practice these transitions in one function. The exercise supplies a session reader and a state setter, so you can concentrate on what the result establishes.
Refresh the account view from a session result
0 / 20 points
Files in the editor
Edit the body of refreshAccount in src/account-state.ts. The types and
function signature are supplied.
Task
Complete one asynchronous function using its two supplied arguments:
readSession()returns a promise for{ user }, whereuseris an account ornull. It rejects if the session could not be checked.setAccount(state)records the account view. Call it with anAccountState.
Before calling readSession, set the view to { kind: "unknown" }. Call
readSession once and wait for its result. Keep the view unknown while the
promise is pending, even if a previous view showed a signed-in account.
- A successful result with a user sets
{ kind: "authenticated", user }, using the returned user’s values. - A successful result with
user: nullsets{ kind: "anonymous" }. - A rejected read leaves the view unknown. Handle the rejection inside
refreshAccount; its returned promise should resolve after that handling. A failure does not establish that the visitor is signed out.
Assume one refresh runs at a time and setAccount does not throw. The supplied
reader already handles the HTTP response; translate its result into account
state.
Four 5-point checks cover pending, authenticated, anonymous, and failed reads.
Check and submit
Choose Submit solution below the editor to run the grading checks and read
the feedback. The editor submits src/account-state.ts.
Refresh the session at startup and after successful login or logout. Logout follows authenticated(user) -> unknown -> session read -> anonymous; a failed read stays unknown. Registration in this API tells the user to log in instead of establishing a session.
Client state guides buttons; the API checks permission on every request. If a protected resource request returns 401 after the session expires, refresh the account view.
We can now connect a request to an account and show what the client knows about that session. Before allowing an edit, the note service still needs to check whether this account owns the note. The next chapter develops that permission decision and extends it to other resource relationships.
Check Your Understanding
- Why should a resource handler use the resolved session instead of the browser’s displayed user?
- What does HttpOnly prevent, and what does it leave possible?
- Why is unknown session state different from anonymous state?
- What makes logout invalidate a copied credential?