The Threat Model: What Malicious JavaScript Can Still Do

RFC 10017’s central security problem is not simply that a browser might store an access token in the wrong place. It is that malicious JavaScript can execute as part of the browser-based OAuth application, with the same origin and many of the same privileges as the legitimate code. This chapter examines what that attacker can do, including the more serious case in which they obtain a new set of tokens without reading the application’s stored credentials. The result is a practical test for any architecture: distinguish protection against stealing an existing token from protection against obtaining or exercising new authority.

When the code shares the origin, it shares the authority

A browser origin is the combination of a scheme, hostname, and port. Browsers use it as a basic boundary: they isolate browsing contexts with different origins, restrict access to resources, and enforce rules for cross-origin requests. The boundary is useful precisely because code inside one origin is granted capabilities that code outside it does not have. That also means an attacker who gets code to run inside the application’s origin is not an ordinary outside observer.

RFC 10017 uses XSS and the compromise of remote code files as examples of how malicious code can gain that foothold. The document does not say these compromises are unavoidable. It recommends preventing them with context-sensitive output encoding and sanitization, careful control of third-party resources, Subresource Integrity, nonce- or hash-based Content Security Policy, origin isolation, and HTML sandboxing. But threat analysis must still ask what happens if those defenses fail. Once the code is executing in the application’s runtime, the browser does not label its instructions “untrusted” while granting the legitimate application the same operations.

The attacker can inspect data on the current page and interact with other same-origin browsing contexts. They can send requests from the application’s origin, read origin-based storage such as localStorage and IndexedDB, and interfere with the application’s normal execution. They can override event listeners, replace ambient functions used by the application, and alter application-level defenses. The problem is therefore broader than an exposed database entry. A design that hides one variable but leaves the attacker inside the process has not necessarily hidden the operation that uses that variable.

The simplest OAuth-specific scenario is single-execution token theft. Malicious code runs, obtains the current access token or refresh token from whatever storage the application uses, sends it to an attacker-controlled server, and stores or abuses it. The two credentials have different consequences. An access token normally gives short-term access to a resource server until it expires or is revoked. A refresh token can be exchanged for new access tokens and may remain valid for hours or days, so its theft can support longer-term impersonation.

Short lifetimes and refresh-token rotation can reduce the damage of some one-time theft. A shorter access-token lifetime narrows the period in which the stolen value is useful. Rotation can make reuse of an old refresh token detectable and allow corrective action. Those measures are valuable, but they describe the case in which the attacker gets a snapshot. They do not describe an attacker who remains in the application and keeps observing or interfering with its credential lifecycle.

That more advanced case is persistent token theft. Instead of sending the current token once, malicious code can arrange to collect the latest credentials repeatedly while the browser remains online. It can wait for a fresh access token or refresh token and choose when to use it. An attacker can even arrange for the legitimate application not to use the newest stolen refresh token—for example by clearing the application’s copy or waiting until the browser goes offline—so ordinary rotation detection may not expose the theft. The important change is temporal: the attacker is no longer racing the application’s expiry schedule; they are following the application’s current state.

There is another path that does not require exporting a token at all. Malicious code can proxy requests through the user’s browser, making the same kind of call the legitimate application makes and processing the response. If the application attaches an access token to an outgoing request, the malicious code can mimic that behavior. If another component automatically augments the request, the attacker can cause that component to augment an attacker-selected request as well. The request is sent from the same browser context, so it can look like an ordinary application action.

This is client hijacking. It is less powerful than directly possessing unrestricted user tokens in some situations, because the attacker remains subject to policies enforced by the client and resource server. A strict CORS policy, for example, may reject requests from a compromised frontend origin to a particular resource server. But a resource server without such a restriction can still receive adversarial requests from the compromised application. An HttpOnly session cookie prevents script from directly reading the cookie; it does not prevent code already running in the application from asking the application’s backend to perform an action that the user is allowed to perform.

These scenarios establish two different defensive jobs. The first is foothold prevention: reduce the chance that attacker-controlled code executes at all. The second is authority limitation: reduce what that code can steal, obtain, or exercise after execution. Browser storage choices mostly affect the first token-theft scenario and the attacker’s access to existing credentials. They do not, by themselves, answer what the attacker can request from a trusted provider or through a server that accepts the browser’s session.

It also helps to separate an attack scenario from its consequence. A stolen refresh token can be used in a Refresh Token grant to obtain access tokens and can support long-term impersonation until the authorization server expires, rotates, or revokes it. A stolen access token usually gives shorter-lived authority, but possession of it lets an attacker send arbitrary requests to a resource server that accepts it: the attacker can choose the HTTP method, destination URL, headers, and body. Client hijacking is different again. The attacker may not control the credential directly, but can inspect or change the page and cause the application or its backend to perform actions. A restrictive resource-server CORS policy may limit some of those requests, while a permissive one leaves more room for adversarial use.

This distinction prevents a binary definition of “protected.” A Web Worker may keep a refresh token away from ordinary application code while still exposing the access token needed for the page’s API calls. DPoP may make an exported application token harder to replay while leaving a newly issued attacker-controlled token usable with the attacker’s key. Rotation may reveal reuse in a one-time theft while failing to reveal a persistent attacker that keeps the legitimate application from spending the newest refresh token. Each defense can be meaningful at one edge of the threat model and irrelevant at another. A sound design therefore states which scenario it is addressing, which consequence it is limiting, and which authority remains available through the origin.

The attack that does not need the stored token

The fresh-token scenario is the decisive boundary for a public browser client. Imagine that the application has taken unusual care with storage. Perhaps it has kept the refresh token away from the main window, avoided persistent storage, and used sender-constrained tokens. Those measures may make an existing credential harder to exfiltrate. They do not stop malicious code from using the application’s origin to start another authorization transaction.

The sequence described by RFC 10017 begins after the attacker has achieved code execution. The malicious script installs a way to observe the result of a flow, such as monitoring the URL of a same-origin frame or receiving a message. It then inserts a hidden frame and initializes it with an authorization request. If the user still has an active session with the authorization server, the request can proceed without a new interactive login and produce a new authorization code. The attacker reads that code from the same-origin redirect, sends it to a server they control, exchanges it for tokens, and abuses the resulting access token and optional refresh token.

The crucial fact is that this is a new flow, not a theft operation. The attacker is not asking, “Where did the legitimate application put its refresh token?” They are asking the authorization server for another token set through the same public client behavior available to the application. In a public browser client, the redirect URI and application origin carry much of the client’s identity. Code running in that origin can inspect a same-origin frame that contains the redirect result. The attacker’s flow can therefore be indistinguishable from a legitimate flow at the point where the authorization server makes its decision.

PKCE remains important for its intended job: it helps ensure that an authorization code intercepted from a transaction cannot be redeemed without the verifier associated with the client instance that initiated that transaction. But the fresh-flow attacker can initiate a flow of their own. They can arrange the transaction and its key material, then redeem the code they caused to be issued. This is why RFC 10017 does not treat PKCE as a complete defense against malicious code in a public application origin. PKCE protects the code exchange; it does not tell the authorization server that the code was requested by the application’s benign logic rather than by code already controlling that application.

The same limitation applies to common token-hardening measures. A short access-token lifetime does not help much when the attacker receives a new access token with its own lifetime. Refresh-token rotation does not stop an independent refresh token that was never part of the legitimate application’s rotation sequence. DPoP can make a stolen application token harder to replay because use requires proof from a private key, but the attacker who obtains a fresh token can bind it to a key pair under their own control. The attacker then has both the token and the key needed to produce the proofs for that token.

The RFC considers proposals that would try to prevent silent authorization. An authorization server could block requests from iframes, but a malicious script could vary the presentation and use a popup or pop-under. Browser-only applications may also rely on hidden frames to establish or bootstrap authentication state, so a blanket prohibition would damage established behavior. Requiring user interaction in every Authorization Code flow would make silent issuance harder, but it would burden ordinary sign-in and single sign-on, and repeated consent can produce fatigue rather than reliable scrutiny. The document therefore treats these options as ineffective or impractical rather than as general remedies.

sequenceDiagram participant M as Malicious script participant B as Browser application origin participant F as Hidden same-origin frame participant A as Authorization server participant T as Attacker server participant R as Resource server M->>B: Execute in application context M->>F: Start silent authorization request F->>A: Use user's existing authorization session A-->>F: Return authorization code M->>F: Observe same-origin redirect result F-->>M: Expose authorization code M->>T: Forward code and attacker-controlled flow data T->>A: Exchange code for new tokens A-->>T: Return independent access and refresh tokens T->>R: Use newly obtained authority

The diagram also explains why “the token is isolated” is an incomplete conclusion. It describes a different route to authority, one that goes through the authorization server rather than through the browser’s storage APIs. Storage isolation can still be worthwhile: it can protect a refresh token from a simple exfiltration script, narrow the exposure of an access token, or reduce the impact of a disk or cross-tab compromise. But it cannot make a compromised public origin unable to behave like an OAuth client unless the architecture places a stronger authority boundary somewhere else.

There is a final residual risk even when tokens are not extractable and new tokens cannot be redeemed by the attacker. Malicious code can still use the browser as the legitimate client, send requests through an application backend, alter what the user sees, and attempt actions that the application permits. That is why RFC 10017 treats client hijacking as inherent to browser applications once arbitrary code runs. The right goal is not to claim that a browser can become invulnerable. It is to decide which authority should be absent from the browser, which requests should be mediated, and which controls can limit the consequences when the runtime is compromised.

That test points toward the architectural difference that matters most: a server-side component acting as a confidential OAuth client can keep the browser from redeeming a fresh code with the component’s credentials and can keep OAuth tokens outside the application runtime. The remaining question is what that boundary costs, and how it should be implemented without turning the backend into an unbounded proxy.

References