---
title: 'Authentication in the Browser'
source: 'https://academia.sh/en/courses/frontend-architecture/authentication-in-the-browser'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

## What Cookie Attributes Do

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.
