Modern web applications are typically implemented as single-page applications, or SPAs, using technologies such as Angular, React or Vue. A single-page application is deployed as HTML, CSS and JavaScript code, and executes in the user's browser.
The original OAuth 2.0 specification stated that frontend web applications had to use the Implicit flow. In March 2019, the OAuth 2.0 Security Best Current Practice deprecated the Implicit flow in favor of the Authorization Code flow with PKCE (Proof Key for Code Exchange). Without going into the details here, frontend web applications can now use an Authorization Code flow, allowing these applications to obtain refresh tokens.
Awesome, right?
Well, there are quite a few security implications of handling refresh tokens in the browser. In this article, we investigate the security properties of refresh tokens in the browser. We investigate why frontend web applications need refresh token rotation and what we gain by using refresh token rotation. Next, we dive into concrete attack scenarios that bypass refresh token rotation and discuss how sensitive SPAs should use a backend-for-frontend to secure tokens.
Let's start by taking a closer look at refresh tokens in the browser.
Using an Authorization Code flow with PKCE, a frontend web application can request identity tokens, access tokens and refresh tokens. With a refresh token, the frontend application can quickly obtain new access tokens. As a result, the authorization server can reduce the lifetime of access tokens to five or ten minutes. Doing so reduces the potential window of abuse for stolen access tokens.
On the other hand, frontend web applications are public clients, which cannot handle client credentials securely. As a consequence, these clients cannot authenticate themselves to the authorization server during an OAuth 2.0 flow. This limitation impacts the security of the code exchange, where a client exchanges an authorization code for tokens. Without client authentication, there is no guarantee that the legitimate client is exchanging the authorization code.
Mobile and native applications suffer from the same problem. For these clients, the OAuth 2.0 specifications introduce a concept known as PKCE, or Proof Key for Code Exchange. In a nutshell, PKCE helps ensure that only the client that initializes an Authorization Code flow can exchange the authorization code with the authorization server.
This PKCE mechanism, originally created for mobile applications, also applies to browser-based applications. By using PKCE, a browser-based application can ensure the integrity of the code exchange step in the Authorization Code flow and avoid other attacks, such as code injection.
But what about a Refresh Token flow? When using a refresh token, confidential clients also have to authenticate. Public clients, such as browser-based applications, do not authenticate during the Refresh Token flow. So in a typical frontend application, refresh tokens issued to frontend web applications are bearer tokens.
In practice, this means that if an attacker manages to steal a refresh token from a frontend application, they can use that token in a Refresh Token flow. To counter such attacks, the OAuth 2.0 specifications mandate that browser-based applications apply a security measure known as refresh token rotation.
Before we discuss the details of refresh token rotation, let's first discuss the most common threat to frontend web applications.
Frontend web applications are built using HTML and JavaScript and execute in the browser of the user. This frontend application operates as an autonomous OAuth 2.0 client application without relying on a backend component. This pattern allows frontend applications to use access tokens to access APIs directly. Unfortunately, it also means that the frontend application is solely responsible for storing and handling access tokens and refresh tokens.
One of the most considerable challenges in securing frontend web applications is preventing the execution of malicious JavaScript code. The ability to execute malicious code in the context of a frontend application allows the attacker to manipulate the application’s behavior. One common consequence of such an attack is the theft of tokens from the client. Since both access tokens and refresh tokens are bearer tokens, nothing stops an attacker from abusing such stolen tokens.
So, how does malicious JavaScript code find its way into a frontend application?
Unfortunately, numerous attack vectors can lead to the execution of malicious code in a frontend application. One attack is known as Cross-Site Scripting (XSS). In such an attack, the attacker can provide malicious data to the application, which renders that data in the user's browser (e.g., displaying a restaurant review from another user). If the application renders the data in an insecure way, a maliciously crafted restaurant review can result in JavaScript code execution. Through that vulnerability, the attacker can inject code that extracts tokens from the user's browser.
Another attack vector relies on the inclusion of remote content or code files. Many modern applications load JavaScript libraries from Content Delivery Networks (CDNs), load third-party services by including a JavaScript file, and load ads from advertisement providers. If the attacker compromises any of these remotely included pieces of content, the malicious code will execute in the victim application. Through the remote compromise, the attacker gains a foothold in the application. Real-world attacks often use this pattern to inject credit card skimming malware.
A third attack vector follows from the way modern applications are developed. Virtually every web application relies on third-party modules and libraries loaded from registries such as NPM. If one of these dependencies contains malicious code, that code will be included in the application bundle. Scary real-world stories highlight targeted attacks against applications through specific NPM modules.
Regardless of the specifics, these attack vectors all have one thing in common: a successful exploitation results in malicious code execution in the user's browser. Even worse, that malicious code runs within the application's execution context. From the browser's perspective, there is no difference between the malicious code and the legitimate application code. As a result, the malicious code can do anything that the legitimate application can do.
Bringing all this back to OAuth 2.0 and refresh tokens highlights a weakness: Once malicious code is injected into the application, it can read and extract the application's tokens. Since refresh tokens are effectively bearer tokens, the presence of malicious code poses a significant problem.
The threat of token theft is well-known in the OAuth world. Consequently, the OAuth 2.0 specifications recognize the danger of bearer refresh tokens in frontend web applications. These OAuth 2.0 specifications require additional security measures for refresh tokens in public clients to mitigate this problem.
One option is the use of Sender Constrained Tokens. Such tokens are bound to a secret that is only known to the client. When using such a token, the sender has to prove possession of the secret. Without such proof, the receiver will reject the token. One example application is a public client running on a mobile device. Such a client can use mTLS, where the secret is securely stored on the device. A second example is the use of DPoP, or Demonstration of Proof-of-Possession. DPoP is an application-level mechanism and is still experimental.
The second option is the use of "refresh token rotation." Since frontend web applications cannot easily use Sender Constrained Tokens, the recommendation is to use refresh token rotation for frontend applications. When refresh token rotation is enabled for a client, refresh tokens can only be used once. Every time the client uses a refresh token, the authorization server issues a new access token and a new refresh token. When the client wants to run another Refresh Token flow, it uses the refresh token that was issued last. The timeline shown below illustrates this concept:
When a client uses a refresh token, it always receives a new refresh token for next time. As a result, refresh tokens are only used once.
However, when an attacker uses malicious JavaScript code to steal a client's refresh token, something interesting happens. The client application is unaware that the refresh token has been stolen, so it keeps using the refresh token to obtain new access tokens (and refresh tokens). The attacker, who has stolen a refresh token, also wants to get a new access token (and refresh token). As a result, either the attacker or the client application will use a refresh token for the second time, as illustrated in the timelines below:
In these scenarios, the reuse of a refresh token triggers all kinds of alarms with the authorization server. Refresh token reuse likely means that a second party is trying to use a stolen refresh token. In response to this reuse, the authorization server immediately revokes the reused refresh token, along with all descendant tokens. Concretely, all refresh tokens that have ever been derived from the reused refresh token become invalid.
This measure seems drastic but effectively prevents further abuse of stolen tokens. Refresh token rotation in combination with the detection of refresh token reuse significantly increases the security of bearer refresh tokens.
But is this enough to secure tokens in the browser?
Before we dive into concrete attack scenarios, let's revisit the attacker's capabilities. When the attacker injects malicious JavaScript code into an application, that code executes in the user's browser. The code is indistinguishable from legitimate application code and has the same privileges as the legitimate application.
A simplistic attack scenario results in the direct theft of existing tokens from the application. That is precisely the scenario we discussed before, which is also addressed by refresh token rotation. But what else can the attacker do?
Below, we discuss three concrete attack scenarios that bypass or sidestep refresh token rotation. Each of these scenarios can be performed by an attacker with the ability to execute malicious JavaScript code in the application's execution context.
A common misconception is the idea that malicious JavaScript code can only perform a single operation. Nothing prevents the attacker from installing a permanent listener to observe the client when it receives a fresh access token. Such a listener can easily extract each access token received by the application. This scenario is illustrated in the image below:
This scenario is an online attack, where the attacker's access disappears when the user closes the client application.
Like the previous scenario, the attacker can install a listener to extract refresh tokens from the application. As long as the attacker refrains from using the stolen refresh tokens, the authorization server's detection mechanism will not be triggered. Next to extracting refresh tokens, the attacker also monitors client application activity.
The moment the attacker detects that the client has become inactive, they use the latest refresh token to obtain fresh tokens. Doing so will avoid detection by the authorization server since the legitimate client is no longer active. From the perspective of the authorization server, there is no malicious behavior going on. The image below illustrates this scenario:
Note that this scenario gives the attacker access on behalf of the user until the absolute lifetime of the refresh token chain is reached. For many applications, this can be up to 8 or 12 hours.
Instead of stealing anything from the legitimate application, the attacker can simply impersonate the application. The malicious code can perform any operation that the legitimate application performs. Consequently, the malicious code can send requests to an API in the same way as the legitimate application does. These requests will carry legitimate access tokens, just like requests sent by the legitimate application. In essence, the attacker can make arbitrary API calls through the legitimate application.
This technique is not a new attack vector. Traditional session-based web applications suffer from a similar problem, which is known as session riding.
The final scenario is the most dangerous. Instead of extracting the application’s tokens or impersonating the legitimate client application, the attacker can run a new OAuth 2.0 flow. The new flow is executed in a hidden iframe, making it completely invisible to the user. Under the hood, the iframe-based flow is configured as follows:
The client identifier refers to the legitimate client application.
The prompt parameter is set to none to avoid user interaction.
The response_mode parameter is set to web_message so that the iframe sends the authorization code to the main browsing context.
Running such a flow in an iframe succeeds when the user has an authenticated session with the authorization server. Given that frontend clients typically require a user to log in when the application launches, the presence of such a session is highly likely.
By running such a silent flow in an iframe, the attacker manages to obtain a new set of tokens. These tokens are independent of the tokens that the legitimate client was already using. As a result, no refresh token is ever reused, making this attack undetectable to the authorization server. The image below illustrates this scenario:
Note that in this scenario, an attacker can even bypass the most advanced security mechanisms, such as the application-level Demonstration of Proof-of-Possession. Also note that this scenario gives the attacker full access on behalf of the user for as long as the refresh token remains valid. For many applications, this can be up to 8 or 12 hours.
Unfortunately, history shows us that frontend web applications are hard to secure. XSS has plagued web applications for almost two decades now. While modern JavaScript frameworks make it a bit better, there are still plenty of potential XSS attack vectors in modern applications. These cheat sheets provide more details on securing React and Angular applications.
So, it’s game over then? Well, yes and no.
From a security perspective, it is virtually impossible to secure tokens in a frontend web application. Malicious JavaScript code can do anything the application can do, so if the application can access tokens, so can the malicious code. Security patterns, such as hiding refresh tokens in a Web Worker, can help but are not a definitive solution to address the scenarios we discussed before.
Concretely, non-sensitive frontend applications can rely on refresh tokens with refresh token rotation. For example, an application handling restaurant reviews is not considered sensitive, which reduces the impact of a successful XSS attack. For sensitive applications, this advice does not hold. For example, applications handling personal information, healthcare data or financial operations are extremely sensitive. In such applications, a successful XSS attack has a significant impact.
That's why sensitive frontend applications should avoid handling tokens in the browser. Instead, they can rely on a Backend for Frontend (BFF) pattern, where token handling is deferred to a minimalistic server-side component. The image below illustrates the concept of a BFF:
BFFs are traditionally used to aggregate various APIs into a single coherent API to serve a client application. In our scenario, the BFF also assumes a minimal security role. It accepts requests from the client application, augments them with OAuth 2.0 access tokens, and forwards the request to the API. Similarly, any response from the API is forwarded to the client application.
So, how does a BFF work in practice?
In this scenario, the BFF becomes the OAuth 2.0 client application. Since the BFF runs on a backend system, it can be configured as a confidential client. The BFF is the client, so it initializes the OAuth 2.0 flow that runs in the user's browser. After the first step of the flow, the BFF receives an authorization code, which it exchanges for tokens with the authorization server using client authentication. With the access token, the BFF can forward API requests. With the refresh token, the BFF can obtain a new access token when necessary. Note that using the refresh token requires the BFF to authenticate to the authorization server.
A BFF is shared among hundreds or even thousands of client instances, each operating on behalf of a different user. The BFF keeps track of these users with a cookie-based session. The BFF can keep that session on the server (e.g., in a simple memory store) but can also push it to the client (e.g., in an encrypted session object). The former results in a stateful BFF, while the latter allows the BFF to become stateless. Both approaches are valid.
Note that the BFF needs to follow cookie security best practices to guarantee the security of the cookie. Concretely, this means that to set a cookie with the name “MyBFFCookie,” the following header has to be used: Set-Cookie: __Host-MyBFFCookie=…; Secure; HttpOnly; SameSite. Get more details on cookie security.
When the client sends a request, the BFF uses the session information in the request to retrieve the user's tokens. If the access token is still valid, the BFF can directly forward the request. If the access token has expired, the BFF uses the user's refresh token to obtain a fresh access token before forwarding the request.
Finally, note that from the perspective of the user, nothing changes. The user experience between a frontend client application and a frontend application backed by a BFF is identical.
A backend-for-frontend offers significant security benefits over browser-based applications. The BFF acts as the OAuth 2.0 client, allowing it to apply security best practices for confidential clients. Concretely, this means that:
The BFF is required to authenticate to the authorization server when exchanging an authorization code or refresh token.
The BFF can rely on robust key-based authentication mechanisms (e.g., mTLS).
The BFF can use sender-constrained access tokens and sender-constrained refresh tokens.
Access tokens and refresh tokens are never exposed to the browser.
But what about potentially malicious JavaScript code running in the frontend application? In this scenario, the malicious code can no longer access the tokens since they are only available to the BFF. Cookie security measures (i.e., the HttpOnly attribute) prevent the malicious code from stealing the session with the BFF.
The malicious code can still modify the behavior of the client application. Concretely, the attacker can perform a session riding attack by sending malicious API calls through the BFF. Such API calls are indistinguishable from legitimate requests sent by the client.
However, the BFF is in full control here. Consequently, the BFF can limit the API surface by preventing the client from accessing certain endpoints. Additionally, the BFF can apply traffic analysis patterns to detect suspicious behavior. Examples include detecting a suspiciously large number of operations or observing sensitive operations in an unexpected order.
Finally, keep in mind that the BFF does nothing to stop the malicious code from executing. The attacker can still extract sensitive information or perform social engineering attacks on the user. The only way to prevent such attacks is by following strict secure coding guidelines for the frontend application.
To make a long story short, malicious JavaScript poses a significant threat to browser-based applications. Advanced JavaScript payloads can circumvent existing security mechanisms to steal tokens, even with refresh token rotation and the isolation of refresh tokens.
Taking everything into account, the only solution to secure a sensitive SPA is using a backend-for-frontend that follows OAuth 2.0 security best practices for confidential clients.
Non-sensitive SPAs can follow the OAuth 2.0 guidelines for browser-based applications. Such SPAs handle tokens directly and rely on refresh token rotation to detect token reuse.
But most importantly, follow secure coding guidelines to avoid malicious JavaScript gaining a foothold in the application in the first place. For more information on best practices, please download our white paper OAuth 2 for Single-page Apps: Recommended Practices.
To learn more about the security of OAuth 2.0 and OpenID Connect in single-page applications, check out Philippe’s online courses or reach out to Philippe directly for help with your applications.