Cookie vs localStorage (Web Storage API) for Authentication Tokens
It is often discussed where should one store authentication or other sensitive tokens – in cookies or localStorage? I think either of the two can be chosen as long as one understands the differences between them across certain aspects like storage, communication, cross-site and cross-origin access and security.
Note: We will not consider using sessionStorage
in this post because its data lasts as long as the tab/browser is open and is destroyed when closed. Generally, authentication tokens are supposed to live beyond tab/browser closures.
Storage
Cookies allow us to add some state to HTTP requests/responses which are stateless. We can store approx 4 KiB data per cookie which I think is practically enough to store most authentication or long-lasting session tokens with extra relevant information.
On the other hand, since localStorage
is meant for client-side storage, we can store large amounts of data with it, which is about 5 MiB per origin.
Just based on the storage differences, I don’t think there’s any advantage of using one mechanism over the other. Although do remember that cookies are sent with every HTTP request, so large amounts of data in the request and response payloads will negatively impact performance (higher request and response times). So if you’re planning to store very large JWT tokens for instance, then localStorage
is a better option with an (optional) identifier stored in a cookie.
Communication
All the cookies stored in the browser that are meant for a host are automatically sent in the HTTP request as part of the Cookie
header. No manual intervention is required here, which means less work for developers.
With localStorage
though the auth or session tokens that you will store in the browser will have to be “manually” pulled and sent into each request that requires authentication. If you are doing this, then a good approach is to send these tokens as a bearer token in the headers (Authorization: Bearer <token>
).
Although one downside of this approach (sending tokens manually) is that if you have resources (images, videos, etc.) that require authentication and are loaded directly in HTML tags, they won’t work. You’ll have to load them using one of the fetch APIs (JS) with the right tokens sent in the request payload.
Same-Site vs Same-Origin Access
Read this web.dev article first to understand the difference between “same-site” and “same-origin”.
But to summarise, two different URLs are considered same-origin if their scheme, full hostname and port are the same (the path is irrelevant). Hence https://example.com and https://example.com:80/foo belong to the same origin, but any change in the scheme (http://example.com
), port (https://example.com:8080/foo
) or hostname (https://foo.example.com
or https://differentsite.com
) would be considered cross-origin.
On the other hand, two URLs are considered same-site (not same-origin) if their scheme and eTLD + 1 portion (read the web.dev article linked above) of the hostname match. Again the path of the URL is irrelevant. So https://example.com
is same-site as https://foo.example.com
(different hostname but same eTLD + 1), https://example.com:8080
(different ports don’t matter) but it is cross-site when compared to http://example.com
(different scheme) or https://differentsite.com
(different domain or eTLD + 1 altogether).
With the two definitions out of the way, we must know that cookies can only be shared across all same sites. If a cookie is set with Domain=example.com
it will be sent in requests to https://example.com
, https://foo.example.com
and https://bar.example.com:8080
(different subdomains and ports but still same sites).
What are the implications of this? You can use this feature of cookies to provide SSO where a user signs in once into https://accounts.yoursite.com
and then can remain signed in to https://mail.yoursite.com
, https://chat.yoursite.com
and https://otherproduct.yoursite.com
.
SSO cannot be easily achieved with localStorage
across subdomains because they store data separately for each origin (same-origin). The Web Store APIs (localStorage
and sessionStorage
) offer isolation according to the Same-origin policy.
Hence, your JS code running on https://example.com
cannot access the localStorage
data for http://example.com
(different scheme), https://example.com:8080
(different port) or https://foo.example.com
(different subdomain). Whereas cookies can be “shared” between the last two examples at least – different ports and different subdomains but same sites.
Though SSO cannot be “easily” achieved with localStorage
, it can still be done in a tricky way. Once a user signs in to https://accounts.yoursite.com
, you can postMessage()
the authentication tokens into iframes that are sourced (src
) to your other subdomains. The tokens can then be stored in each subdomain’s respective storage.
This is not a clean solution, but it works. If you have ten subdomains, then you will need to do this across ten iframes for each subdomain. But if you have 2, then it’s probably just fine.
Security
This is an important aspect to grasp.
Both cookies and localStorage
are vulnerable to XSS attacks where a malicious script may get injected into the HTML document that can perform various state-changing actions on behalf of the victim (POST
request to bank transfer route) as well as access the auth tokens.
In either case, XSS attacks must be prevented with proper output encoding and/or Content Security Policy (CSP). For cookies, you should also set the HttpOnly
(prevent JS from accessing the cookie) and Secure
(sends cookies to https:
hosts/origins only) attributes.
Cookies are also vulnerable to CSRF attacks since they are automatically sent on every eligible request. CSRF is an attack where the attacker makes malicious authenticated requests to the vulnerable site on behalf of the victim to perform unwanted operations (post a tweet, publish a video, make a bank transfer, delete a code repo, etc.). These attacks are somewhat tied to using cookies. localStorage
on the other hand, are immune to CSRF attacks because they are not automatically sent by the browsers in a request.
CSRF attacks in the case of cookies can be mitigated by using Lax
cookies. The assumption here is that you have control over all the available/possible subdomains and you don’t perform any state-changing operations via safe HTTP methods. If that’s not the case, then you will have to implement a token-based mitigation which is more work if you’re not using a framework or libraries.
Conclusion
With the understanding of the different aspects described above, I think based on storage and communication, neither cookies nor localStorage
have a major benefit over one another. But when it comes to SSO across subdomains, choosing cookies will make your job easier.
In terms of security, since you should always follow best practices to prevent XSS, it may feel like localStorage has the upper hand here in terms of less work, but sometimes just setting the SameSite=Lax attribute to your cookie will be enough for CSRF protection. Also, good backend or frontend frameworks make implementing CSRF protection via token-based mitigation strategies quite trivial.