Skip to content
academia.sh

Lesson 20 / 22

Authentication in the Browser

The distinction between authentication and authorization, comparing session cookies with bearer tokens, the readability of a token's body, a risk–benefit analysis of the three storage options, and what cookie attributes do.

Contents

The measurement entry form now holds its value, applies its rules identically on both sides, and presents its errors so that everyone can perceive them. One question the form asks remains: who is entering this measurement? Not everyone can write to North Slope’s records; the observer must be recognized first.

This lesson covers the client side of the identity flow: where the proof of identity the server issues, once it recognizes the observer, lives in the browser, which request it travels with, and which risks that decision opens and which it closes. The scope is deliberately narrow — the whole of client-side security, including content security policy and cross-origin resource sharing, is covered in the Client-Side Security topic of the Frontend Quality course. Only the identity flow itself is here.

Authentication and Authorization Are Separate Layers

Authentication is the answer to “who are you.” The observer supplies a username and password, the server verifies them, and produces a proof of identity.

Authorization is the answer to “can you do this.” A recognized observer might have the right to write measurements to station NS-01, and not to NS-02.

Keeping the two apart is not wordplay. The authorization decision is always made on the server; what the client does is not show a button the user cannot reach. This visual filtering is not a security control, it is a usability improvement: hiding the button does not stop the request from being built some other way.

Two Ways to Carry It

There are two ways for the proof of identity to travel with a request.

A session cookie carries a random identifier the server generates. The identifier itself carries no meaning; the server looks it up in its session records and finds the matching user. The browser sends the cookie on its own; the application’s code adds nothing to each request.

A bearer token is a signed string that carries the user’s information inside itself. JWT is a common example. The server does not have to look up a record on every request; it verifies the signature and reads the claims in the body. The token is sent by hand, in a header; the browser does not do this on its own.

The distinction has two practical consequences. Being sent automatically leaves the session cookie open to cross-site request forgery (CSRF): a request triggered from another page carries the cookie along with it too. Being sent by hand keeps the bearer token clear of this risk, but requires the token to be stored somewhere — and wherever it is stored opens a new risk. This trade-off is the core of the lesson.

The second consequence of carrying information inside itself is irrevocability. Once the session record is deleted on the server, the session ends at that moment. A signed token, on the other hand, stays valid until it expires; revoking it requires the server to keep a separate denylist — which gives back most of the gain from not keeping records. In practice this is balanced by keeping tokens short-lived.

A Token’s Body Is Not Secret

A token is signed; that means its content cannot be altered, not that it is secret. This difference directly determines what can be put inside a token, and what the client can do with it.

// token-reading.mjs — how readable a token is on the client
import { createHmac, timingSafeEqual } from "node:crypto";

const b64 = (v) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v))
  .toString("base64url");

// --- Server side: issues and signs the token --------------------------------
const SECRET = "a-key-that-lives-only-on-the-server";
const NOW = 1768348800;                       // 2026-01-14T12:00:00Z, fixed for the example

function issueToken(claims, secret) {
  const body = `${b64({ alg: "HS256", typ: "JWT" })}.${b64(claims)}`;
  const signature = createHmac("sha256", secret).update(body).digest("base64url");
  return `${body}.${signature}`;
}

const token = issueToken({
  sub: "observer-3", role: "writer", station: "NS-01",
  iat: NOW, exp: NOW + 900,                   // 15 minutes
}, SECRET);

console.log("token:", token);

// --- Client side: reads the body without verifying the signature -----------
function readBody(token) {
  const parts = token.split(".");
  if (parts.length !== 3) return null;
  try { return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); }
  catch { return null; }
}

const claims = readBody(token);
console.log("body the client reads:", JSON.stringify(claims));

// Expiry checking is a usability calculation, not an authorization decision.
const ALLOWANCE = 30;                          // clock skew allowance (seconds)
const hasExpired = (c, now) => now >= c.exp - ALLOWANCE;
for (const now of [NOW, NOW + 600, NOW + 880, NOW + 1000]) {
  console.log(
    `t=${String(now - NOW).padStart(4)}s  left=${String(claims.exp - now).padStart(4)}s`,
    ` should renew: ${hasExpired(claims, now) ? "yes" : "no"}`);
}

// --- Server side: signature verification ------------------------------------
function isSignatureValid(token, secret) {
  const [header, payload, signature] = token.split(".");
  const expected = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
  const a = Buffer.from(signature), c = Buffer.from(expected);
  return a.length === c.length && timingSafeEqual(a, c);
}

// A token with a tampered body: role changed from "writer" to "admin".
const [header, , oldSignature] = token.split(".");
const tampered = `${header}.${b64({ ...claims, role: "admin" })}.${oldSignature}`;

console.log("\noriginal token, server verification  :", isSignatureValid(token, SECRET));
console.log("tampered body, server verification   :", isSignatureValid(tampered, SECRET));
console.log("tampered body, client reads          :",
  JSON.stringify(readBody(tampered).role));
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJvYnNlcnZlci0zIiwicm9sZSI6IndyaXRlciIsInN0YXRpb24iOiJOUy0wMSIsImlhdCI6MTc2ODM0ODgwMCwiZXhwIjoxNzY4MzQ5NzAwfQ.HWgZfVP_bRqv1UsSGiRGB5Bi-jMTXDyGc1q9eRbpi78
body the client reads: {"sub":"observer-3","role":"writer","station":"NS-01","iat":1768348800,"exp":1768349700}
t=   0s  left= 900s  should renew: no
t= 600s  left= 300s  should renew: no
t= 880s  left=  20s  should renew: yes
t=1000s  left=-100s  should renew: yes

original token, server verification  : true
tampered body, server verification   : false
tampered body, client reads          : "admin"

The output gives three rules.

The body is text anyone can read. Encoding is not encryption; anyone holding the token reads what is inside it. This is why nothing beyond the user’s identity and permission names goes into the body — no personal information, internal system identifiers, or secret values.

A tampered signature is caught, but only on the server. The tampered token did not pass server verification; the signature does not match the body. The client still reads that same token’s body without trouble, and sees the role as “admin.” So the role the client reads is not a claim in force, only a display. The interface can draw a menu with this information; it cannot make an access decision with it.

Expiry checking is a usability calculation. The client can look at the exp field and renew the token before it expires, so the user avoids a 401 response. But this check is not an authorization decision: the client’s clock might be wrong, the body might have been tampered with. The thirty-second allowance in the example is set against clock skew and pulls the decision earlier. The server still decides.

Token Storage: Three Options

Where the token is stored reduces to three options, and each opens a different risk. In the table, “if a script runs” describes the situation where a script that does not belong to the application runs on the page — cross-site scripting, XSS.

Criterion In-memory variable Web Storage HttpOnly cookie
Readable if a script runs Yes (in the same execution context) Yes, directly No, unreachable from a script
Forgery (CSRF) risk None, sent by hand None, sent by hand Present; narrowed by attributes and a token
On page refresh Lost Kept Kept
Across tabs Not shared Shared Shared
Leakage to subdomains None Separate per origin Happens if Domain is written
Server-side revocation Easy if short-lived Easy if short-lived Instant with the session record

No single “correct option” follows from the table; two arrangements are common and defensible.

The access token in memory, the refresh token in an HttpOnly cookie. The short-lived access token is never written to any persistent store; it is lost on page refresh and reacquired with a refresh request carried by the cookie. If a script runs, the access token can be read, but its lifetime is limited to minutes; the refresh token can never be reached.

Everything in an HttpOnly cookie, with the session record on the server. No identity value can be read from a script, and revocation is instant. In exchange, forgery risk is taken on and narrowed with attributes.

The arrangement to avoid is keeping the refresh token in Web Storage: a long-lived proof of identity sits where a script can read it. Keeping the short-lived access token there, by contrast, is a trade-off — readability risk taken on in exchange for surviving a page refresh.

The storage choice has a limit: if a script can run at all, the store barely matters. Even if the token sits in an HttpOnly cookie, a script running on the page can send a request on the user’s behalf. The storage decision narrows the risk; it does not eliminate it. The real defense is preventing the script from running at all, and that is the subject of the Frontend Quality course.

In arrangements that use cookies, four attributes carry a direct security function, and all four are declared in the header the server writes.

HttpOnly blocks the cookie from being read by a script. Without this attribute, the cookie becomes just as readable as Web Storage.

Secure ensures the cookie is only sent over an encrypted connection. It has no exception outside local development addresses.

SameSite decides whether the cookie is sent on requests triggered from another site. Strict never sends it in any outside context, which makes a user arriving from an external link look signed out; Lax sends it on top-level navigations but not on embedded requests; None sends it in every case and can only be used together with Secure. This is the attribute that actually narrows the forgery risk, but it is not considered sufficient on its own: state-changing requests are additionally protected with a forgery token.

Path and Domain determine the paths and domains the cookie will be sent to. Writing Domain opens the cookie to every subdomain; a compromised subdomain then affects the session too. When left unwritten, the cookie goes only to its own host name, which is usually what is wanted. The __Host- prefix placed on a cookie’s name turns this narrowing into a rule: a cookie with this prefix is only accepted with Secure, without Domain written, and with a root path.

The Client’s Identity State

The answer the application gives to “is a session open” is also a piece of state, and follows the distinctions from the State Management topic. This state is derived not from the token itself, but from whether a token exists and from the server’s responses.

At application startup, the identity state is unknown. In an arrangement that keeps the token in memory, there is nothing on hand once the page refreshes; the only way to learn whether the session is still active is to ask the server. This means identity state has a fourth value: unknown. Interfaces written without this value appear signed out for a moment at startup and throw the user onto the login screen.

Route guarding is also tied to this state. The route guard rule from the Routing topic manages visibility on the client side; a protected route’s data is still protected by getting a 401 from the server. The client’s guard can be bypassed; the server’s cannot.

Summary

  • Authentication is the “who are you” question, authorization is “can you do this”; the authorization decision is always made on the server, and the client only manages visibility.
  • A session cookie is sent automatically by the browser and is open to forgery; a bearer token is sent by hand and must be stored.
  • A token being signed says its content cannot be altered, not that it is secret; the body is readable by anyone, and the role the client reads is not proof of authorization.
  • Expiry checking on the client is a usability calculation; it is pulled earlier by a clock skew allowance, but the server makes the decision.
  • The three storage options open different risks; the two defensible arrangements are keeping the access token in memory with the refresh token in an HttpOnly cookie, or keeping everything in a cookie and separately guarding against forgery.
  • The HttpOnly, Secure, SameSite, and Domain attributes narrow the risk; if a script can run on the page, no storage option protects on its own.

Next Step

This lesson presented the token’s short lifetime as a defense, but the short lifetime has a cost: the session dropping every fifteen minutes. If the token expires while the observer is filling out the measurement form, the submission comes back with a 401 and everything they wrote is put at risk. The next lesson builds a way to keep the short lifetime without paying that cost: renewal the user does not notice, gathering multiple simultaneously dropped requests into a single renewal, and the session closing everywhere at the same time.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close