GAP·MAP

Web authentication on the client

[ junior depth ]

Cookie or localStorage changes the attack surface

An authentication credential in localStorage is available to JavaScript running in that origin. localStorage also persists across browser sessions. If an XSS payload runs as your site, it can read the stored value and send it elsewhere.

const token = localStorage.getItem("access_token");

await fetch("/api/profile", {
  headers: { Authorization: `Bearer ${token}` },
});

An HttpOnly cookie works differently. JavaScript cannot read its value, while the browser can attach it to HTTP requests whose URL and cookie attributes match.

Set-Cookie: __Host-session=opaque-value; Path=/; Secure; HttpOnly; SameSite=Lax

Secure restricts transmission to HTTPS. HttpOnly blocks JavaScript access to the cookie value. SameSite controls some cross-site sending, and __Host- requires a host-only cookie with Path=/ and no Domain attribute in supporting browsers.

Wrong "HttpOnly prevents XSS from using the session."

Right HttpOnly prevents script from reading that cookie. Malicious same-origin code can still make HTTP requests from the victim's browser, and those requests can carry the cookie.

XSS and CSRF are different questions

XSS gives attacker-controlled code the privileges of code from your origin. That code can read localStorage, change the page, and make requests with the user's credentials.

CSRF starts from another site. The attack relies on the browser attaching ambient credentials, such as cookies, to a request the target user did not intend. SameSite=Strict or SameSite=Lax reduces when cookies are sent cross-site, but MDN describes SameSite as defense in depth rather than a complete CSRF defense.

For a cookie-authenticated API, a state-changing request can carry a CSRF token in a custom header. The server has to validate it.

await fetch("/api/email", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": csrfToken,
  },
  body: JSON.stringify({ email }),
});

Other primary defenses documented by MDN include validating Fetch Metadata headers and requiring non-simple state-changing requests while keeping CORS restrictive.

Session cookie versus JWT is the wrong comparison

A cookie is a storage and transmission mechanism. A JWT is a representation for claims. You can put a JWT in an HttpOnly cookie, so choosing one does not choose against the other.

The useful comparison is between two session models:

  • In a centralized session, the browser holds a meaningless session ID. The server uses it to look up session state.
  • In a decentralized session, the client holds signed session state, commonly represented as a JWT. A resource server can verify the signature and use that state without contacting the issuer for each request.

Centralized state makes server-side invalidation direct because the server can delete the stored session. A self-contained token is harder to revoke before it expires. MDN recommends the centralized model when the application architecture does not need decentralized sessions.

Wrong "JWT means localStorage, and session ID means cookie."

Right Pick the session model and the browser storage mechanism as separate decisions. Either credential is valuable if stolen.

[ middle depth ]

Follow the authorization code through the browser

The current IETF browser-apps draft requires browser-based public clients to use the authorization-code flow with PKCE. OAuth Security BCP advises clients against the implicit grant, which returns an access token in the authorization response and exposes it to leakage and replay risks. The code flow returns a short-lived authorization code and obtains tokens from the token endpoint instead.

When browser JavaScript itself is the OAuth client, it is a public client. Its source reaches the user, so a shared secret embedded in the bundle cannot prove the application's identity.

The flow has two browser-visible trips and one token exchange:

  1. The application creates a fresh, high-entropy code_verifier. It derives an S256 code_challenge and keeps the verifier for this transaction.
  2. The browser navigates to the authorization endpoint with response_type=code, the challenge, its registered redirect URI, and the client ID.
  3. After authorization, the authorization server redirects the browser to that URI with a code.
  4. The application posts the code, client ID, redirect URI, and original verifier to the token endpoint. A cross-origin token endpoint must permit the browser request with CORS. The server derives the challenge from the verifier and compares it with the challenge bound to the code before issuing tokens.
GET /authorize?
  response_type=code&
  client_id=browser-app&
  redirect_uri=https%3A%2F%2Fapp.example%2Fcallback&
  code_challenge=DERIVED_VALUE&
  code_challenge_method=S256
POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=RETURNED_CODE&
client_id=browser-app&
redirect_uri=https%3A%2F%2Fapp.example%2Fcallback&
code_verifier=ORIGINAL_RANDOM_VALUE

PKCE protects the code exchange. An attacker who intercepts only the code lacks the verifier. It does not create a confidential client or prevent malicious JavaScript already running in the application's origin from acting as that application.

Wrong "PKCE is a client secret for SPAs."

Right The verifier is fresh for each authorization request. A static secret shipped in browser code is available to users and cannot authenticate the deployed application as a confidential client.

Protect the redirect transaction

The redirect URI must belong to the registered client, and OAuth Security BCP requires exact string matching except for the documented localhost port exception for native apps. Browser clients must also defend the redirect URI against CSRF. Current guidance allows enforced PKCE for this purpose. Other specified choices include a unique verified state value carrying a CSRF token, or an OpenID Connect nonce when its required checks apply.

const returned = new URL(location.href).searchParams.get("state");

if (returned !== pendingTransaction.state) {
  throw new Error("OAuth transaction mismatch");
}

Do not describe state as a place to put unprotected return URLs or application data. When its contents matter, the client must protect them against tampering and swapping.

Refresh tokens extend the session and the risk

An access token can have a short lifetime. A refresh token lets the client ask the token endpoint for another access token without repeating the complete authorization flow. A stolen bearer refresh token is especially valuable because it can mint new access tokens.

For public clients, OAuth Security BCP requires sender-constrained refresh tokens or refresh-token rotation. With rotation, each successful refresh returns a new refresh token and invalidates the previous one.

{
  "access_token": "new-short-lived-access-token",
  "refresh_token": "new-rotated-refresh-token",
  "token_type": "Bearer"
}

If an invalidated predecessor appears later, the authorization server has evidence of reuse. It cannot tell whether the legitimate client or attacker sent that token, so it revokes the active refresh token and forces a new grant. The browser-apps draft also requires either a maximum refresh-token lifetime or expiry after inactivity.

Wrong "Rotation makes a refresh token safe to keep forever in localStorage."

Right Rotation detects replay after reuse. It does not prevent same-origin malicious code from reading localStorage, using the current token first, or asking the provider for tokens as the application.

[ senior depth ]

Start with the malicious-JavaScript capability

Once attacker-controlled JavaScript runs in the application's origin, storage isolation answers only part of the threat. The IETF browser-apps work separates two capabilities: extracting tokens already in storage, and obtaining tokens from the provider in the same way as legitimate application code. When the authorization server permits a silent flow through an existing user session, even completely isolated storage cannot remove the second capability from a browser-only public client.

An HttpOnly session cookie still improves the outcome. The payload cannot read and export that cookie value. It can send authenticated requests through the victim's browser, so authorization checks, XSS defenses, request integrity controls, and short session lifetimes still matter.

Wrong "The safest browser storage API solves XSS for OAuth tokens."

Right Storage can reduce token exfiltration. Code controlling the origin can still operate the client and may start a fresh authorization flow.

As of July 2026, the IETF's browser-specific guidance is revision 27 of an Internet-Draft, published July 6, 2026 and intended as Best Current Practice. It defines three architecture patterns. Its draft status matters because the document can still change before RFC publication.

Three places to put the OAuth boundary

In a backend-for-frontend, the backend is the confidential OAuth client. It performs the code flow, retains access and refresh tokens, and associates them with a cookie session. Browser API calls go through the BFF, which attaches an access token when forwarding the request to the resource server.

Set-Cookie: __Host-session=opaque-value; Path=/; Secure; HttpOnly; SameSite=Strict
await fetch("/bff/orders", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": csrfToken,
  },
  body: JSON.stringify(order),
});

This removes OAuth tokens from browser storage and prevents direct token extraction there. The BFF sees all proxied API traffic, and malicious same-origin code can still send requests to it with the user's browser session.

A token-mediating backend also handles OAuth and keeps refresh tokens away from the browser, but it returns access tokens to the frontend so the browser can call resource servers directly. This reduces refresh-token exposure while leaving access tokens available to the application's JavaScript environment.

A browser-based OAuth client performs the code exchange and stores its own tokens. It has the largest malicious-JavaScript exposure of the three patterns. The July 2026 draft does not recommend this architecture for business applications, sensitive applications, or applications handling personal data.

The architecture choice belongs to the client threat model. It is separate from whether an access token is opaque or formatted as a JWT.

Refresh-token rotation is detection with containment

OAuth Security BCP requires public-client refresh tokens to be sender-constrained or rotated. The browser-apps draft adds bounded lifetime requirements: set a maximum lifetime or expire the token after inactivity. When a pre-established maximum exists, rotation must not push the replacement beyond the initial token's lifetime.

Rotation retains the relationship between replacements. Suppose R1 is exchanged for R2, then an attacker later submits R1:

R1 accepted  -> issue R2, invalidate R1
R1 reused    -> detect reuse, revoke active R2

The authorization server cannot identify which presenter was malicious. Revoking the active token forces the legitimate client through a new authorization grant too. That cost is part of the containment mechanism.

Rotation has a hard boundary. It limits continued replay of an extracted refresh token and provides a reuse signal. It does not stop malicious code that uses the current token before the legitimate client or proxies requests through the active browser. When the provider permits silent authorization through the user's existing session, the code may also obtain an independent set of tokens through a new flow.

Wrong "Short access-token expiry plus rotation ends the attack as soon as XSS runs."

Right Those controls reduce the duration and replay value of stolen credentials. They do not restore trust in a compromised origin.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Browser & Network

was this useful?