Cookies and CORS for Secure, Portable Static App Authentication
Note: This is some pretty hard-core authentication nerd content. You've been warned.
Authentication for static web applications can be tricky (for a general overview of the challenges, see this article). At Divshot, we run a constellation of static applications which all talk to our Ruby API server using CORS. Until recently, each of our static applications would authenticate and store its own access token for the API following, in rough terms, the OAuth 2.0 Implicit Grant Flow. While this worked, it exposed a number of less-than-ideal user experience issues:
- Because each app authenticated independently, our users were sometimes met with repeat login and/or authorization screens.
- Ensuring that a logout action logged users out of all of our applications proved difficult and unreliable.
- While stored securely, the idea of storing Bearer tokens locally (and in multiple apps, at that) was a bit unsettling.
I decided it was time to investigate alternatives. My most important goal was to consolidate authentication for our various web apps to a single point.
First Attempt: Root Domain Cookie
Because our static apps all exist as subdomains of
divshot.com, my first thought was to generate an access token and set it as a (SSL-only) cookie on the root domain. With this cookie, each application could simply read the access token and access the API without having to store its own copy. When we wanted to trigger a logout, we could simply wipe out the cookie and the user would be instantly logged out of all of our systems.
I wrote some exploratory code along this line but realized that there were two primary issues with the approach:
- We would still be storing a bearer token as a browser cookie. While this isn't the end of the world, it wasn't ideal.
- I wanted each of our applications to be independently identified. We have multiple avenues to perform the same action, and if all apps used the same access token it would be difficult to differentiate which was which.
So while this solution would have been better than what we had, it still didn't seem to cover all the bases. What else could I do?
I'm a strong believer in building applications with third-party API access in mind from day one. It helps you properly consider authorization patterns early on and makes it easy to open up down the road when you're ready. It also makes it much easier if you build new applications such as, for instance, a command-line client.
At the end of the day, however, user experience matters. A lot. I started wondering if it might be possible to use the browser session on our API server to authenticate users to our first-party applications. My first question: if we have a session cookie, how does that work with CORS?
Web Standards Are Awesome
Now ordinarily, it would be very dangerous to allow cookies to send through with cross-domain requests. If the browser sent cookies along without the server explicitly permitting it, the security implications would be frightening. Luckily, that's not the case. Instead, we have
When creating a cross-domain AJAX request you can set the
withCredentials option to true. This tells the browser to send any cookies that are set for the requested domain along with the request (and also to allow for HTTP Basic authentication). However, the server must respond with the special
Access-Control-Allow-Credentials: true header or the browser will not proceed with the request.
This means that on the server we have complete control over when we allow cookies to be used on cross-domain requests. Once I read more about
withCredentials, I realized that a perfect solution might be within reach.
The New Solution
The solution I implemented is relatively straightforward. Each of our applications has a unique Client ID. This is registered on our back-end and associated with a list of approved origins for cross-domain requests.
When we want to use the browser session for authentication with our applications, we simply make a CORS request with two special features:
withCredentialsoption is enabled
Authorizationheader is set to
When the server receives such a request, it makes several checks to ensure everything is properly authorized:
- Using the info in the Authorization header, we look up the client application with the given ID and make sure that it is approved for session-based authentication.
- We cross-check the Origin header of the request against the list of approved origins for the specified client application.
- We make sure that there is actually a user logged in with an active session based on the cookies coming from the browser.
If all of these checks pass, we set the
Access-Control-Allow-Origin header to the origin of the request (it can't be
withCredentials is enabled) and
true. We then continue on and perform a normal, authenticated API request.
As compared to our previous method of authentication, session-based CORS has several key advantages.
- It's more secure as no authorizing bearer token is ever stored on the user's machine.
- It "just works" if the user has logged in to our central authentication server.
- Logging out is now centralized and will carry through all apps.
- We can safely retrieve basic user information even on non-SSL encrypted sites since the API always uses SSL and no bearer token needs to be stored.
- We can easily track the originating application for each request.
- Adding additional applications requires almost no effort
- Everything works perfectly along-side our existing token authentication, so no disruption of existing platforms needed to happen
Static Apps are Serious Business
We are big believers in the potential of static web applications as key pieces of infrastructure in any company's web stack. While it's easy to get caught up in the excitement of third-party "back-end as a service" providers we are just as excited about the advantages of static web technology for fully custom back-ends.
Modern browser technology (and especially CORS) allow you to create completely seamless static web user experiences. As shown in this article, even browser-session authentication is entirely within reach.
How are you authenticating your static web apps?