Using OAuth, OIDC in Cross Domain Browser Apps
State of OAuth & OIDC
OAuth 2.0 and Open-ID Connect have evolved over the past decade into a dizzying array of specifications and best practices.
Choosing the right path, whether trying to build an OAuth/OIDC implementation or trying to integrate with one is challenging. In this post I’m going to concentrate on what the options are for a browser based application and in particular for a browser application that needs to communicate with services hosted by third parties.
Note that this problem space is continuously changing as new attack vectors are teased out and countermeasures are rolled out to close those vectors off. So the advice in this post may quickly become stale, nonetheless the core issues underpinning this problem space are pretty fundamental and hopefully this post can help clarify them.
Cookies versus Tokens
In modern browser applications, there are two chief means of authorizing requests:
- Cookies: Issued by the application server, sent by the browser automatically with any request to the application server that issued the cookie, storage of the cookie managed by browser. Best practice is to mark them
HttpOnly
, so JavaScript cannot access them. - Access Tokens: Most often JSON Web Tokens, must be manually added to a request (i.e. using JavaScript), storage is responsibility of each web application. Typically these are issued by an OAuth/OIDC identity provider.
Cross Site Request Forgery and Cross Site Scripting
Cross Site Request Forgery (CSRF) is where an attacker lures a victim to a site the attacker controls, whilst the victim is logged into the target site the attacker wishes to gain access to. Because the victim is logged in, the attacker may be able to make a cross domain attack from the attacker site to the target site. We’ll explain a bit more about how this can be accomplished below.
Cross Site Scripting (XSS) is where the attacker finds a way to input data into an application and that data is not escaped/sanitized properly before being included in the HTML of a victim web application. This gives the attacker the ability to run whatever JavaScript they choose in the context of the victim web application. Effectively they gain control of the application and can make it do whatever they wish. With the dynamism of the Browser platform, the attacker gains the ability to reprogram the victim web application as they please.
XSS is probably the most common web application vulnerability, because preventing it requires inhuman levels of rigour in making sure that absolutely every single piece of data ingested by the application is validated, sanitized and then equally carefully escaped in every single spot in the application where that data could be displayed. You can go a long way to mitigating XSS using some best practices, but it’s still hard to be confident you have fully mitigated XSS.
CSRF is also very common, because it requires a good deal of care to ensure that a request did originate from the expected client code, and not from some code under the attacker’s control. However it is far more tractable to secure against CSRF than XSS, as long as you put in place a few best practices.
In any web application you want to prevent an attacker gaining access to the cookies or access tokens, they represent the keys to the application.
Marking cookies HttpOnly
makes them invisible to JavaScript, preventing XSS attacks from stealing cookies. Hence, when you do use cookies always mark them HttpOnly
. You also need to ensure no intermediaries sitting between the browser and the application server can intercept the cookies
so you must only use HTTPS. Thus you should also mark the cookie Secure
. Thus the chief strength of cookies (marked HttpOnly
) is their resistance to XSS.
If you use access tokens to secure access to your web application’s server APIs then you must store the access token somewhere in the browser storage. There’s a litany of places you can store them, sessionStorage, localStorage, IndexedDB, JavaScript heap, or even in a WebWorker scope. They each have pluses and minuses, but the bottom line is they are visible to JavaScript code, so if you do have an XSS vulnerability in your web application, then you have to assume an attacker can get their hands on your access tokens. No matter where you squirrel them away, at some point your application’s code is going to have to retrieve the access token and attach it to the request. If you have an XSS then the attacker will try to gain control of that piece of code at last resort, although it is much more likely that they can just scan sessionStorage, localStorage, IndexedDB and find the token there.
So you might be thinking: ‘Well, then just use Cookies’, but unfortunately it’s not that simple.
The first problem is the fact that the browser always sends the cookie with every request back to the server. If an attacker can lure a victim to a site under the attacker’s control, because the browser will send the cookie, they can launch a cross origin request from the attacker site, which will impersonate the signed in user.
There is a straightforward mitigation to this attack. The server must check and verify the Origin
request header and reject any requests that do not originate from the expected client. Or put another way the cookie must be bound to a particular
origin, and must only authorize requests from that origin. This prevents the attacker mounting a CSRF attack.
There is an edge case to this, which is that a HTML Form can submit a form to any origin and on older browsers this request will not include an Origin
header, so it cannot be validated. It is even possible to construct the form data so that it could
parse as either HTML form data or JSON data. The way to stop this attack is to check the Content-Type
request header. An HTML form will be submitted with the application/x-www-form-urlencoded
content type. My recommendation is that no API
should accept form data. Note that a form can also submit form data as multipart/form-data
or text/plain
so you have to be careful about accepting those media types as well.
The second problem is that, increasingly browsers are reluctant to send cookies along with cross origin requests. This is because this has become the prime mechanism through which users are tracked as they navigate the web. The direction the browsers are headed is clear, cross origin cookies are going away.
If you need to make cross origin requests, you are going to have to use access tokens. The challenge is to keep those tokens secure, which means assuring your application does not have any XSS vulnerabilities.
The corollary is that if you are not making cross origin requests, do not use tokens, just use cookies, mark the cookie HttpOnly; Secure; SameSite=strict
, and be sure to bind that cookie to your origin. Finally check the Content-Type
of all requests.
One strength of access tokens is that they are not automatically added to the request. They naturally act as a CSRF Token. In other words an attacker needs to accomplish an XSS against your web application (to steal the tokens) before they can mount a CSRF attack. Put another way, if you use tokens, and you do not have any XSS vulnerabilities, then it becomes practically impossible for an attacker to launch a CSRF attack against your web application.
Design for the worst case
Before this post starts sound too optimistic 😉, we have to remember that as engineers, we have a responsibility to design for the worst case. We already talked about how prevalent XSS vulnerabilities are.
Let’s take a few moments to depress ourselves a bit more, considering the challenges of eliminating XSS, and then when we are done with that, we can wallow in some thoughts about how XSS free web applications can still be compromised!
The first challenge of XSS is the many shapes and forms it comes in. Web Applications are all about ingesting data, perform some processing on the data, and displaying the results. It’s the age old problem of Garbage in, Garbage Out, but made much worse than in older architectures, because every single piece of data ingested runs the risk of being weaponized by an attacker, by becoming executable code. You have to worry about XSS in your own code. You have to worry about inadvertent XSS bugs in the code you depend on, you have to worry about attackers maliciously adding attacks into the code at the source code level and also at the hosting level.
My own feeling is the culture of deep dependency chains (which is one of the things that has been key to letting the web platform flourish) needs to moderated. Each new dependency needs to be carefully reviewed and reluctantly accepted. You need to be able to quickly uptake new versions of dependencies, and avoid getting stuck on old versions. You need a development process that lets you quickly validate that uptaking a new version does not regress your application. You need to be proactively tracking vulnerability reports and cross-checking against your dependencies.
For a moment, let’s assume you do manage to accomplish the above, and assure you have an XSS free web application. Are you safe? The answer is no, there’s at least one other scenario you need to consider.
In any large enough user population some of your customers will have compromised browsers. The browser, or the device the browser is running on will have malware, be it a browser plugin or a keylogger or whatever. One way or another, access tokens will leak out of the browser.
So that’s the worst case you have to design for. You have to design for the fact that your access tokens will get stolen.
You cannot 100% prevent access tokens being stolen, so you have to design for mitigating the outcome when tokens are stolen. The most common and best known mitigation is to make the tokens short lived. That way an attacker might only have a a short time window to exploit the token.
In my view another fundamental mitigation required for any token used in a browser application, is that it is bound to a specific origin. In other words the token must only be authorized if the accompanying request originates from the expected client. This makes it harder (but not impossible) for the attacker to exfiltrate the token and mount a CSRF from elsewhere. They have to mount attacks from the Origin they’ve broken into, and it is always harder for a criminal to remain undetected when they remain at the scene of the crime.
Similarly the token must be bound to a particular audience, it must not be possible for the attacker to use the same token with a different site.
It is also crucial that you have some mechanism to invalidate tokens, which is often a problem for ‘stateless’ signed JWT access tokens. It might be a blunt invalidation (e.g. it invalidates all tokens rather than a specific token), but you do want to have some kind of kill switch.
Refresh Tokens
When you have short lived access tokens, you do not want to ruin the user experience by constantly prompting the user to reauthenticate themselves. That’s why OAuth & OIDC have the concept of Refresh Tokens. These are not access tokens, they do not grant access to anything, but they are long lived and can be exchanged for an access token. This ability to issue new access tokens is a powerful capability, so it becomes crucial to keep refresh tokens secure. In a browser based application you have the same challenges with trying to keep refresh tokens secure as you do with access tokens.
If you do hold the refresh token in the browser, then you need to take the same measures as you do for the access token, ensure that the request originates from the expected client by validating the origin.
One thing you can do is keep refresh tokens on the server side of your application, but this is not foolproof. In this model at some point your browser based code will have to ask the server side to use the refresh token to acquire a new access token and return the access token to the browser based code. If that browser based code has an XSS then all that the attacker has to figure out is how to trigger that logic and intercept the returned access token.
If you do keep the refresh token on the server side, the OAuth server does have to make efforts to ensure the request does originate from the expected server, at least by verifying the client id and client secret, but ideally using something more verifiable like mutual TLS. If you only want to allow server side code to request refresh tokens you should reject any request that has an Origin header (which would indicate that the request originates from browser side code).
When a refresh token is exchanged, that refresh token must become invalidated and a new refresh token issued along with the access token. If the token issuer detects that an invalidated refresh token is re-used then both the current access token and the refresh token must be invalidated. This is called refresh token rotation.
Proxying all requests through the server side
You might think that it might be better to just proxy all cross origin requests through your server side, use only cookies to secure the access between the browser code and your server, because your server is more likely to be able to keep access tokens and refresh tokens secure.
This approach is worth due consideration, it may be appropriate, but it is not without its own downsides.
The first is that you are plowing your own path, rather than following a recognised pattern and so it may be harder to evaluate the security posture of your bespoke approach compared to sticking to more well trodden patterns mandated by the OAuth/OIDC Specifications and Best Practices.
The second is that to some degree, your server becomes a reverse proxy for those third party services that you interact with, worse you are effectively combining those distinct origins into a single origin (your server origin). This may have unintended consequences both for your application and the services that your are interacting with. Also, your server side becomes even more of a single point of failure in your architecture.
Staying in the game
You probably know the aphorism about outrunning the bear. Bears run faster than humans, to avoid being eaten by a bear you don’t need to be the fastest runner, you just need to be faster than the slowest runner.
Web Application security is kind of like that, it is very hard to assure that your application cannot be broken into by an attacker, in many ways the best protection you might have is to present a more difficult target than someone else.
In other words keeping your application secure is a never ending race, you have to plan and design for that. What is best practice today will become outmoded in the coming years as novel attack vectors are discovered.
The fundamental challenge is that due to the dynamic nature of the Web Platform, every piece of data has the non-zero possibility of becoming executable code. Contrast this to operating systems where one of the pillars of security hardening has been to try put clear boundaries between data and code.
Recommendations
To try and sum up the recommendations in this post:
-
If you take nothing else from this post, focus on eliminating XSS vulnerabilities. If you are not actively testing for and seeking out XSS in your application, if you are not evaluating your dependencies for XSS vulnerabilities then your Web Application is in real danger of becoming an easy target for attackers.
-
Review the OWASP Cheat Sheet for XSS Prevention, if you can apply the rules outlined consistently across your code base it will significantly strengthen your XSS defences.
-
Leverage Content Security Policy to ensure that JavaScript can only be loaded from a whitelist of origins. This prevents an attacker being able to co-opt their own external code base into an XSS attack.
-
-
To prevent CSRF with cookies, ensure the cookie is bound to a single origin, avoid accepting form data, because of the risk of CSRF on older browsers.
-
Cookies must be marked
HttpOnly; Secure; SameSite=strict
. -
If you have a single origin Web Application, use cookies, if you have a cross origin application, then you must use access tokens and OAuth/OIDC for those cross origin requests.
-
Mitigate the risks of access tokens being stolen. Bind tokens to a single origin, and a single audience. Keep them short lived. Have a kill switch to invalidate all tokens for a user (or worst case, for everyone, but aim for a user).
-
Design your developent process to allow you to continuously redeploy your application. On the Web you never get finished shipping your application.
-
Review OAuth Security Best Current Practice and OAuth 2.0 for Browser based Apps. Both documents provide a wealth of information about how to strengthen the security posture of browser applications that use OAuth.
-
If you are considering using the Implicit flow for your browser application, you probably want to use the Authorization Code flow with PKCE instead.
-
Do not use the Resource Owner Password Credentials Flow, it has a baked in assumption that the user credentials are in the form of username and password. With the emergence of multi-factor authentication and WebAuthn, this becomes an invalid assumption.