The Token Is an Architecture Decision

RFC 10017, the IETF’s Best Current Practice for OAuth 2.0 in browser-based applications, is often read as a question about storage: should a token go in memory, a worker, a cookie, or persistent browser storage? Its more consequential question is architectural. Which component becomes the OAuth client, which component can hold long-lived authority, and which component sends requests to protected resources? This series uses that question to help readers choose a boundary they can defend rather than a browser API that merely appears discreet.

Start with the actors, not the storage API

OAuth connects several roles that are easy to blur together. A browser-based application is code dynamically downloaded and executed in a browser, usually JavaScript but potentially another browser runtime such as WebAssembly. It asks an authorization server to issue credentials after the resource owner has authorized access. The application then uses an access token to call a resource server, which protects the actual API or data. The authorization server and resource server may belong to the same organization, but OAuth treats them as distinct parties when describing the trust relationships.

An access token is the credential used for a protected-resource request. A refresh token is a different credential: it is used to obtain a new access token when the current one expires or becomes invalid, and it can therefore represent longer-lived authority. A session cookie is different again. In a server-mediated design, it can be only a handle that lets a backend recover the user’s server-side session; it need not be an OAuth token and need not be readable by application JavaScript.

That difference is easiest to see in a single request. The browser may send a session cookie to an application backend. The backend can use the associated session to select the user’s OAuth access token, attach that token to a request for the resource server, and return only the resulting application response. The browser has participated in an authenticated operation, but the OAuth credential did not have to enter the frontend’s JavaScript environment. In another design, the browser may receive the access token and attach it itself. The visible user experience can be similar while the authority boundary is entirely different.

Refresh authority changes the stakes again. An access token usually represents a bounded opportunity to call a resource server until it expires or is revoked. A refresh token is a means of obtaining more access tokens, so control over it can extend the attacker’s access over time. A cookie can also be highly sensitive, especially when it represents a live session, but its security meaning depends on how the server interprets it and whether scripts can read it. Treating all three values as interchangeable “tokens” hides the design choice the RFC is asking us to make.

That distinction matters because the browser is not a confidential place. The source code is delivered to the end user, so a value embedded in it cannot function as a secret shared only with the authorization server. A browser client is consequently normally a public client. It can identify itself, but it cannot prove its identity with a provisioned secret that every copy of the application contains.

The modern protocol baseline is the Authorization Code grant with Proof Key for Code Exchange, or PKCE. Earlier browser deployments often used the Implicit grant because cross-origin exchanges were difficult under the same-origin policy. With widespread CORS support, a browser can exchange an authorization code at the token endpoint, and PKCE binds redemption to the client instance that began the transaction. That protects the code exchange. It does not decide where the resulting access or refresh tokens should live, and it does not make the public browser runtime confidential.

Why a browser origin changes the question

A browser origin is defined by the scheme, hostname, and port. Browsers use origins to isolate contexts, restrict resources, and enforce CORS. That boundary is valuable, but it also defines the reach of a malicious script. If an attacker gets JavaScript to execute in the application’s origin—through an XSS flaw or a compromised remote code file—the code is not placed in a weaker category than the legitimate application. It can operate in the same execution context, inspect page data, interact with same-origin frames, issue requests from the origin, read origin-based storage, and tamper with application-level defenses.

The obvious result is single-execution token theft: malicious code reads the application’s current credentials and sends them elsewhere. A more persistent version installs a mechanism that keeps collecting the latest credentials while the browser remains active. Short access-token lifetimes can limit the window of a one-time leak, and refresh-token rotation can provide detection and correction in some cases. But an attacker that continually observes the application can keep pace with the credentials it uses. The impact is not limited to reading a string from localStorage.

A compromised application can also proxy requests through the user’s browser. It can mimic the legitimate application, use the browser’s cookies or attach the access token in the expected way, and process the resource server’s response. The request may look like an ordinary request from the application because it is originating in the same context. Even a server-side application using an HttpOnly session cookie cannot generally prevent malicious code from making actions that the legitimate client is allowed to make. This is client hijacking, and it is a residual risk of browser compromise rather than a storage-format problem.

The most important distinction is that an attacker may not need the application’s existing token at all. In the fresh-token scenario described by RFC 10017, malicious code can create a hidden frame and start a silent Authorization Code flow using the user’s existing authorization-server session. It can observe the authorization code in a same-origin redirect, forward it, and obtain an independent set of tokens. An isolated token store does not matter to this path because the attacker is asking the provider for new authority rather than stealing old authority. DPoP does not close it either: for the newly obtained tokens, the attacker can use a key pair under their own control.

This gives the storage question two tests. First, can the attacker obtain an existing credential from storage? Second, can the attacker obtain or exercise authority through the provider and the application’s permitted request path? Storage mechanisms affect the first test. Architecture determines much of the second. Controls such as output encoding, cautious use of third-party scripts, Subresource Integrity, CSP, origin isolation, and sandboxing help prevent the initial foothold, but once arbitrary code shares the application’s runtime, a perfect browser vault cannot be assumed to solve the entire OAuth problem.

Three locations of authority

RFC 10017 compares three principal patterns, presented in decreasing order of security. The difference is not a branding distinction; it is the location of the OAuth client and the path by which resource requests acquire authority.

In a Backend for Frontend, the backend is the OAuth client and acts as a confidential client. It completes the Authorization Code flow, associates the access and refresh tokens with session state, and forwards the browser’s application requests to approved resource servers. In the server-side session form, the browser receives only a protected session handle. RFC 10017 also describes client-side sessions in which signed, optionally encrypted token material may be carried in a protected cookie without being exposed to frontend JavaScript. This arrangement removes the most direct script-based token-extraction paths and prevents a compromised public frontend from redeeming a new code with the backend’s client credentials. It does not stop the compromised frontend from attempting permitted requests through the BFF, so CSRF defenses and strict outbound controls remain part of the boundary. The RFC strongly recommends this architecture for business applications, sensitive applications, and applications handling personal data, while also recognizing the cost of deploying and operating the proxying backend.

A token-mediating backend keeps the OAuth flow and refresh token on the server, but returns an access token to the browser so the browser can call resource servers directly. This is lighter because the backend does not proxy every request. It is also less secure than a BFF: malicious code can steal the access token, or mimic the application and ask the backend for the latest one. The design still protects refresh authority from direct browser extraction and prevents the public frontend from exchanging a new authorization code without the backend’s credentials. It is therefore a middle position, justified when direct resource-server calls are a real system requirement rather than simply a preference for fewer server requests.

In a browser-only OAuth client, the browser performs the flow, stores the access token and any refresh token, and calls the resource server directly. This is straightforward, but it leaves the application exposed to the full set of OAuth-specific attacks associated with malicious browser code. The client must use Authorization Code + PKCE, exact registered redirect URIs, an appropriate redirect CSRF defense, secure in-browser messaging when secondary windows or frames are involved, and the CORS behavior required for cross-origin endpoints. If refresh tokens are issued, their lifetime and replay behavior need explicit limits, such as rotation or sender constraint and an absolute or inactivity-based expiry.

The ordering is therefore also an ordering of control. The BFF places OAuth processing and resource-server translation behind a server boundary; with a server-side session, both tokens remain on the server, while a client-side session relies on protected cookie state rather than exposing token material to frontend JavaScript. Token mediation places refresh authority there but returns access authority to the browser. The browser-only pattern accepts that the public origin will possess the credentials and tries to reduce exposure through protocol and storage choices. None of these patterns makes browser compromise impossible. They differ in what a compromised browser can steal, what it can ask a provider to issue, and how much authority it can exercise without a backend policy standing between it and the resource server.

Consider the same user action—a frontend asks to display a protected set of records. With a BFF, the browser sends its session context to the backend, and the backend makes the authorized resource request. With token mediation, the frontend first receives an access token and makes that request itself; the refresh token can remain on the backend, but the access authority has crossed into the origin. With a browser-only client, the frontend also owns the flow that obtains the credentials and must decide how to retain them between requests or page loads. The screen can look identical in all three cases. What changes is which compromised component can produce the next authorized request and which party can observe or constrain it.

This is why “server-side” is not sufficient as a description. A backend that merely hands an access token back to the browser has moved some responsibility, not all authority. Conversely, a browser cookie is not automatically a server boundary: a cookie readable by JavaScript can be exposed and may also be sent automatically to the application’s domain. The relevant questions are where the OAuth client runs, whether the browser can read or request the credential, and whether the resource-server path is mediated by a policy-bearing component.

The practical question is thus not “which storage API is safest?” It is “which component should be trusted with which credential, and which component should be allowed to make the protected request?” Once that is clear, storage becomes a subordinate design choice: useful for reducing a specific exposure, but unable by itself to turn a public browser origin into a confidential client.

flowchart LR AS[Authorization server] RS[Resource server] subgraph BP["BFF pattern"] BA[Browser application] BC[BFF confidential OAuth client] BT[(Access and refresh tokens)] SC[HttpOnly session cookie] BA -->|sends session cookie| BC BC -->|stores| BT BC -->|authorized request| RS BC -->|sets| SC SC -->|not an OAuth token| BA end AS -->|code and token exchange| BC subgraph MP["Token-mediating backend"] MA[Browser application] MB[Confidential backend] MT[(Refresh token)] MA -->|session check| MB MB -->|stores| MT MB -->|returns access token| MA MA -->|direct access-token request| RS end AS -->|code and token exchange| MB subgraph OP["Browser-only client"] OA[Public browser OAuth client] OA -->|access token| RS end AS -->|code plus tokens| OA

References