If I were to implement a common OpenID Connection pattern on a SPA, I might have the following relationship:
Auth server <-----------> Client (browser) <-----------> App API server
The user would be redirected to the auth server to login, and an HTTP-Only cookie is set on the auth server with the user's ID token (whose payload contains user details and is signed by a secret) and auth token. Then the auth server redirects the user back to the app with the tokens in the hash string, so they can be persisted.
The hash would have to be set by having the API return something like:
res.send(`<script>
location.replace('${callbackUrl}#access_token=${accessToken}&id_token=${idToken}')
</script>`)
rather than a HTTP redirect with the token in the query string (because that would probably be logged), and the client-side app can use history.replaceState()
to wipe the tokens from browser history. The browser strips these tokens from the hash so the user won't accidentally bookmark or share this URL containing the tokens, then it persists the tokens to localStorage
.
I gathered from this article on Stormpath that localStorage
isn't the greatest of ideas, since it's vulnerable to XSS (any arbitrarily executed JS living on the same page can read the token from localStorage
).
So they advocate for using HTTP-Only cookies to store tokens instead. In this approach, the auth API redirects the user back to the app with headers to set HTTP-Only cookies with:
access_token
id_token
csrf_token
These cookies are set on the auth API server. The UI would also need to know the value of the csrf_token
. If the authentication happened with an AJAX request, we could send it in a header; in the case of OpenID, where the app redirected to the API domain, we'd have to use the script
approach and send it in the hash for the app UI to receive it. As I understand it, these approaches are roughly equivalent in terms of security -- whether read from the hash or from a header, either way the browser must store this CSRF token to send it in subsequent requests.
Angular's HTTP service handles the former approach (header) by grabbing the token and saving it to a cookie on the UI domain. Then it automatically sends requests to the auth API with the X-XSRF-Token
set to that value it has stored in the cookie. Then the API can confirm that this given token matches the one in its csrf_token
cookie and determine if the user submitted this request.
This is considerably more complicated than the localStorage
approach. The article proposes that this is more secure because localStorage
is vulnerable to XSS -- an attacker embedding malicious JS on a page on the UI's domain can steal the localStorage
values. (Obviously XSS can be mitigated, but there are certainly situations that arise where we could be vulnerable to XSS, such as a CDN the UI uses getting hacked and sending malicious scripts, or simply a npm package that appears benign and is used in the UI but secretly captures localStorage
in the background.)
But how is this CSRF approach any more secure? After all, the CSRF token must be stored in a cookie on the UI, and it can't be a HTTP-only cookie, since requests to the API must include the token in a header. So wouldn't this mean the same XSS vulnerability? The malicious script could read the CSRF cookie on the UI and start sending requests to the API using it.
Am I missing something here? How is the CSRF cookie more secure than putting a token in localStorage
, if in either case, the primary means of authenticating with the auth API can be read from the client?