From a CIAM perspective, a B2C/B2B SaaS application will typically deal with a number of different Tokens, each used for a different purpose and each expressed in a particular format. An ID Token, for instance — used by an application to identify an authenticated user and build the user session — is expressed as a JWT. An Access Token, on the other hand — used when calling an API on behalf of a user — is more often than not expressed as a JWT, but could equally be an opaque artefact requiring the use of Token Introspection. Whilst a Refresh Token — used primarily to recreate an expired Access Token without user interaction — is typically opaque.
Each token has a purpose that is well-defined. But how should one go about storing each of them in a way that ensures safety and security? Should they all be treated in the same manner? Or should some be considered more precious than others? As vehicles for identity and data access, tokens are attractive targets for malicious actors; how and where they are stored, then, becomes a crucial aspect of a secure CIAM implementation. Earlier this year, I came across the following post on X/Twitter (whichever your preference 😉) that sparked some debate. I often find it amusing how the various 3rd-party SaaS CIAM vendors go back and forth with each other as each one vies for their position as the superior solution. However, it does highlight an interesting point: what is the best approach to adopt when it comes to token storage, and which strategy offers the most protection long-term?
Whilst Colin was talking specifically about browser-based scenarios — which are arguably the most challenging — SaaS solutions typically incorporate the likes of Mobile Apps, as well as Backend services too (whether as part of a regular Web Application, or as a BFF in some SPA context or the like). So there are typically multiple scenarios, in multiple different contexts, in which token storage needs to be considered. My name’s Peter Fernandez, and in this article, I’m going to take a look at the various token storage options available and how they compare and contrast, in an attempt to shine a spotlight on the particular aspect of token safety and security that I like to refer to as the Token Storage Conundrum.
In-Memory Storage
In-memory typically refers to a situation where a token is stored in a non-persistent manner within a (language) runtime itself; in the case of JavaScript, for example, this means using the built-in dynamic object data structure in which to store values. With a lifetime that only extends as long as the duration of the execution context, in-memory can be considered the most secure mechanism for token storage because a token is not really “stored”: storing tokens typically referres to a persistent manner, and in the Browser, for example, in-memory storage typically means until a page refresh occurs. In a Backend, until a request is complete.
Backends often utilise connection pooling, where an execution context may persist longer than anticipated to service other incoming requests — the upside being that context setup and teardown are mitigated during periods of heavy traffic. However, Backends are what are commonly referred to as Confidential Clients, so inherently more secure. The lifetime of an execution context is also rarely predictable.
Returning to the “tweet” Colin made above, in their latest documentation — at least at the time of writing this article — Auth0 (https://auth0.com) largely recommends in-memory storage as the best option for a Browser context, particularly where a Web Worker is also utilised; see below for more details. Noting a few caveats for consideration. Of course, the downside is that once an execution context expires, all traces of the token are erased — a good thing from a security perspective, but also potentially problematic:
- For an ID Token, this isn’t too much of an issue, as typically an application will use the ID Token to establish the authenticated user session and then discard it. However, depending on your IdP of choice, and also your preferred mechanism for Logout — i.e. Logout from the IdP as well as the application — an ID Token, regardless of its expiration, may need to persist for the duration so that it can be passed via
id_token_hint
as recommended in the OpenID spec. - For an Access Token that is used as part of a security-sensitive operation — such as transferring a sum of money or the like — in-memory storage is the preferable option. Such tokens should be short-lived anyway, ideally used just once (as in to make one API call), and then discarded once used. For less security-sensitive operations, however, having an Access Token that hangs around for longer (e.g. 24 hours, say) will typically be more efficient, allowing use as part of multiple API calls without needing to make corresponding calls to the Authorization Server (i.e. to get a new token to call the API).
- For a Refresh Token, however, in-memory storage is essentially a non-starter. Holding a Refresh Token implies that an application would want to generate new instances of a token at some stage, without user interaction or potentially the user being present at all. The use of persistent storage, then, is key when it comes to utilising a Refresh Token.
One caveat is that use of an in-memory persistent cache — such as Redis or memcached
— offers a good solution for backend confidential clients that is often preferable to using a database (discussed below) when it comes to Refresh Token storage.
From a security point of view, in-memory storage largely relies on using obscurity from a symbolic reference perspective, so one can argue that in-memory is more “security by obscurity”: there’s nothing typically stopping malicious access, it’s just that if malicious code doesn’t know where something is, it can’t find it. In the case of a JavaScript public client, for example, a script isn’t necessarily prevented from accessing a token held in memory; rather, it’s the fact that a symbolic reference to the token is not well-known which makes it harder for a malicious script to find.
By treating in-memory storage as a security-sensitive resource — as in explicitly scrubbing (i.e. overwriting) token memory once a token is no longer required — the surface for attack can be further reduced. Holding a token in memory for no longer than is required should always be preferred.
Web Workers
A Web Worker (see the Mozilla documentation here for more details) is a mechanism for web content to run scripts in background threads: specifically, a Web Worker executes in a separate Browser context where the global window
is unavailable for reference. Thus making it even harder for a malicious script to hunt down a token via some symbolic reference mechanism. Dedicated workers, represented by a DedicatedWorkerGlobalScope
object, can be used to provide sandboxing, as storage is only accessible from the script that first spawned it. In contrast, shared workers, denoted via the use of SharedWorkerGlobalScope
, provide storage that can be accessed from multiple scripts. The OWASP HTML 5 Security Cheat Sheet here has additional information on the use of Web Workers, including some additional things to consider.
Local Storage
Typically associated with Browser execution — i.e. embedded browser execution as well as execution via an externalised chrome — localStorage
allows data to be stored in key/value pair fashion on a public client, where data persists even after the browsing session has ended (i.e. data remains available for future browsing sessions irrespective of whether a browser window or a browser tab is closed). With local storage, there is no automatic expiration. Underlying storage mechanisms typically vary from browser to browser, as well as platform to platform, and with local storage, authentication at the application-level can easily be bypassed: a script executing on behalf of a user who has local privileges will typically have access to local storage. As discussed in the OWASP HTML 5 Security Cheat Sheet here, the recommendation is that local storage be avoided where sensitive information is concerned.
Local Storage is particularly susceptible to what is commonly referred to as Cross-Site Scripting attacks (or XSS for short; see the OWASP Cross Site Scripting Prevention Cheat Sheet for more details). Since it was originally coined, the term has widened to include the injection of basically any content; however, in the context of local storage, an XSS attack typically focuses on malicious script injection in order to steal token information. Stolen tokens can then be used for account impersonation, observing user behaviour, loading external content, stealing sensitive data, and more.
Local storage is most vulnerable to XSS attacks, as there is no way to protect against a malicious script reading or even modifying its content. XSS attacks are often convoluted and complex, and how they work is beyond the scope of this article.
As local storage is arguably the most vulnerable, OWASP advises that any data stored should also not be trusted. Unlike when using the Path
attribute of an HTTP Cookie (discussed below), with local storage, there is also no way to restrict the visibility of an object; every object in local storage is shared within an origin protected by the Same Origin Policy rather than any specific path restriction. So the recommendation is also to avoid hosting unrelated applications that use local storage on the same origin…as all of them would share the same localStorage
:
- For an ID Token, using local storage means that Personally Identifiable Information (PII) could potentially be leaked, particularly where
email
and/orprofile
were included as a scope when an authentication request was sent to the IdP (see the OpenID spec here for more details). As local storage is always accessible by JavaScript, the recommendation is to also avoid using it to store any application-level session information.- Ideally, a Cookie using the
httpOnly
flag, as discussed below, would be the preferred choice for storing anything application-level session-related, particularly if an ID Token must be stored for the duration (see discussion above). However, in a SPA (Single Page Application), the choice between using a Cookie or local storage is arguably an arbitrary one, unless a BFF — Backend For Front-end — pattern is also being employed.
- Ideally, a Cookie using the
- For an Access Token, the use of local storage is best avoided, particularly where the token is being used as part of any security-sensitive operation (such as transferring a sum of money or the like). Due to the lack of adequate protection, storing Access Tokens of any nature in local storage could cause issues. When you consider that some 3rd-party services will also honour any access tokens they issue when calling the
userinfo
endpoint, even when not in JWT format, an (opaque) Access Token can still potentially leak PII!- Again, a Cookie using the
httpOnly
flag (discussed below) would be the preferred choice for storing a longer-lived Access Token. However, for a SPA, the choice between using a Cookie or local storage is again an arbitrary one, unless a BFF pattern is also being employed. As previously discussed, in-memory should still be the preferred choice where Access Tokens for security-sensitive operations are concerned.
- Again, a Cookie using the
- For a Refresh Token, local storage should ideally never be used. A Refresh Token can be used to acquire new tokens without user interaction, so putting a Refresh Token in local storage would be semantically equivalent to making user credentials readily accessible, albeit for a specific scope of resource access.
- Some 3rd party SaaS CIAM solutions, such as Auth0, recommend using Refresh Tokens in a SPA context to mitigate problem scenarios involving Intelligent Tracking and Prevention (a.k.a. ITP2) and the like; see the Auth0 article here for further details. Some would argue that this is due to session architecture limitations associated with the solution itself rather than anything else. ITP2, or similar, may not be an issue for you, but if it is and you are building a vanilla SPA — as in one where a BFF is not being used — then local storage is your only option.
Where local storage of a Refresh Token is necessary, a single-use (sometimes referred to as Rotating) Refresh Token should ideally be used. Single-use refresh tokens come with their own challenges — particularly in respect to multi-threading — however, that is a topic beyond the scope of this article.
Session Storage
Whilst similar in its implementation, the difference here is that while local storage is partitioned by origin only — i.e. utilising Same Origin Policy — sessionStorage
is partitioned by both origin and top-level browsing context. Thus, the data in session storage is only kept for the duration of a session, i.e. only persists for as long as the browser or browser tab with which it’s associated remains open. Again, whilst there is no automatic expiry per se, at least the data contained in session storage doesn’t stay around forever. Session storage is also isolated, so in the case of the browser, each tab has its own separate sessionStorage
block.
Session storage is still vulnerable to XSS attacks, though the attack surface is arguably reduced given that persistence and visibility are more tightly constrained.
Cookie Storage
Designed as a way for websites to easily and conveniently store limited amounts of state information at scale, an HTTP Cookie (also referred to as a Web Cookie, Internet Cookie, Browser Cookie, or simply Cookie for short; see here for more details) is a small block of data typically created by a web server while browsing a website. Cookies are most commonly associated with a Browser context, and whilst also stored on the user device where browsing occurs, there are several notable differences between a Cookie and Local Storage:
When using cookies, clearing the Browser cookie cache will typically force user (re)authentication — in an interactive manner too, if the session with the IdP has also expired.
- a Cookie is more often associated with a regular Web Application than with a SPA.
- a Cookie is included as part of HTTP request/response communication
- typically as part of the HTTP Header.
- a Cookie can have an associated
Expires
, orMax-Age
, so that explicit expiry occurs in an automated fashion - a Cookie
Path
attribute indicates a URL path that must exist in the requested URL in order to send the Cookie.- the optional
Domain
attribute can also be used to specify on which server the Cookie can be received.
- the optional
- a Cookie with the
Secure
attribute will only be sent to the server with an encrypted request over the HTTPS protocol. - a Cookie with the
HttpOnly
attribute can’t be accessed via JavaScript; more on this below.
A Cookie without the HttpOnly
attribute is equally vulnerable to XSS attacks as using Local Storage (see the previous section for more details). So, for a SPA, consider also implementing the BFF pattern if possible in order to provide the most secure option.
A cookie can be created by the backend server as well as the front-end client; data in local storage, on the other hand, is only ever created by the front-end. The exception to this is where the HttpOnly
attribute is used; discussed in more detail below. For a SPA (Single Page Application), the preferred HttpOnly
attribute can’t be used unless a BFF — Backend For Front-end — pattern is also employed. So, for a vanilla SPA (as in one that doesn’t employ a BFF), there is arguably little difference between using a Cookie vs using local storage.
For a vanilla SPA, local storage could be considered marginally more secure (and more efficient, too), as data is not included as part of HTTP communication. For a regular Web Application, an HttpOnly
Cookie, however, would be the preferred choice.
Unlike local storage objects, cookies are transmitted between the client and the server with each HTTP request/response, typically as part of the HTTP header; local storage objects only exist on the browser, though, like local storage, a Cookie sent to the browser can potentially be interacted with from a JavaScript perspective (given the right conditions). The additional attributes that can be used to enforce control over a Cookie — i.e. Max-Age
(Expires
), Path
, Secure
, and HttpOnly
in particular — arguably make it a generally safer option when it comes to token storage, but only if they are/can be used:
The maximum amount of data a Cookie contains is limited; Cookies are transmitted as part of each HTTP request/response, so keeping the number of Cookies to a minimum, as well as the amount of data each Cookie contains, improves overall efficiency from a network perspective. The amount of data contained in a JWT has an impact on whether or not it can be stored in a Cookie.
- For an Access Token, Cookie storage using
HttpOnly
— again, with theSecure
attribute set for good measure — is a reasonable choice, at least for Access Tokens with a longer lifetime. However, again, this is only applicable for a regular Web Application or for a SPA where the BFF pattern is also employed. For security-sensitive operations, short-lived in-memory storage of an Access Token is typically preferable, as discussed above. - For a Refresh Token, that needs to be stored in a public client Browser context, an
HttpOnly
Cookie (ideally with theSecure
attribute set for good measure) should be the preferred option. Ideally, and for the reasons previously discussed, storing a Refresh Token in a browser context should be avoided. However, where such a case is a necessity — as in a SPA without a BFF — a single-use, sometimes referred to as a Rotating Refresh Token, should also be used.
HttpOnly
Ordinarily, a Cookie can be manipulated using JavaScript, so a malicious script could potentially read or even modify information. However, a cookie with the HttpOnly
attribute will be prevented by the Browser from being accessed in a JavaScript context, for example, using Document.cookie
; when using HttpOnly
, a Cookie can only be accessed by the backend server. Cookies that persist security-sensitive information, such as tokens, should always have the HttpOnly
attribute set. This helps mitigate Cross-Site Scripting attacks (XSS), as previously discussed.
As discussed, use of the HttpOnly
attribute is only pertinent for a regular Web Application or for a SPA where the BFF pattern is also being used. The use of the HttpOnly
attribute does not completely eliminate the possibility of XSS, but it does significantly reduce it (particularly when also combined with use of the Path
attribute).
Secure Storage
Typically, a platform native storage mechanism associated with a native application (i.e. a Mobile App or Desktop Application), secure storage — often referred to as Keychain/Keystore storage, particularly in the case of (cross-platform) distribution — utilises specialised device hardware where data is stored cryptographically and in a tamper-proof manner. Using technologies that go by the name of Trusted Platform Module (Windows), Secure Enclave (Apple) and the like, this approach offers extremely secure token storage for confidential clients and a viable/often preferred alternative for public client devices, too:
Secure Storage can be utilised as the underlying storage mechanism for Browser in-memory or Browser local storage; however, in such cases, the use of secure storage is completely under Browser control and hidden from the application.
- For an ID Token, the use of secure storage prevents token misuse, particularly when it comes to the potential leakage of PII in cases where the token has to be stored for the duration (see the previous discussion above for more details). Of course, in an ideal scenario an ID Token would be used to establish the application session and then simply discarded to prevent misuse.
- For an Access Token, the use of secure storage again prevents token misuse, and is a preferable choice for native clients where a Browser may not be utilised at all. Again, security-sensitive operations should prefer to leverage short-lived access tokens that are discarded after an ideally single use.
- For a Refresh Token, secure storage is the recommended choice for native applications in a public client context. Single-use (sometimes referred to as Rotating) Refresh should also be preferred, but as previously discussed, this can introduce challenges from a multi-threading perspective — something inherently more prevalent in a native application context, and beyond the scope of this article.
Embedded Browsing
As described here in the OAuth 2.0 specification, native applications should prefer to use external browsers instead of embedding browsers when it comes to security-sensitive operations, in order to mitigate against Clickjacking attacks. However, techniques involving the use of postMessage
to an iframe
or the like (beyond the scope of this article) can be used to transfer an Access Token to a Browser context if caution is exercised. Never transfer a Refresh Token from a native context to a Browser context, embedded or otherwise, and avoid storage of a browser-transferred token where possible to mitigate XSS attacks.
Database Storage
For a backend implementation — such as a Web Server backend or a BFF — a database offers a mechanism for long-term storage across multiple execution instances. Database storage is implemented in a confidential client context, is queriable, and can be organised in a more complex manner than simple key-value pairs. As a token storage mechanism, the use of a database presents an attractive proposition for long-term persistence. However, the value of using a database as a token storage medium vs. the practicality, particularly from a security standpoint, is arguably questionable:
- For an ID Token, the use of database storage doesn’t really hold much value. In an ideal scenario, an ID Token would be used to establish the application session and then be discarded to prevent misuse; even if the ID Token was required for the duration, an
HttpOnly
Cookie (as described here) would likely be a more convenient alternative. - For an Access Token, caching via the use of database storage in machine-to-machine scenarios — as in the use of Client Credentials grant to allocate an Access Token — could potentially mitigate excessive costs in situations where a 3rd-party SaaS CIAM solution is used. Solutions such as Auth0, for example, charge considerably more for machine-to-machine tokens, but don’t provide restrictions when it comes to machine-to-machine token allocation. As there are security considerations to bear in mind (see below) — and for other scenarios, as in the non-machine-to-machine use cases, the alternatives discussed in the previous sections are preferable — attempting Access Token caching is not advised without judicious security review. Further discussion on this is also beyond the scope of this article.
- For a Refresh Token, on the face of it, database storage is the most resilient option, at least from a user experience perspective. For instance, using database storage can protect against any Authorization Server session expiry precipitating interactive user (re)authentication to call an API. It can also mitigate scenarios where Cookies are cleared, again forcing the user to do the same — i.e. interactively (re)authenticate. However, given the security considerations to be borne in mind (below), interactive (re)authentication is often the most preferable option and one that the user is comfortable with, particularly if the experience is well-designed.
- The use of database storage offers some interesting possibilities when it comes to caching for agent-style operations performed on behalf of a user. OAuth 2.0 Token exchange, for instance, is a complicated feature, which few SaaS CIAM solutions support (so-called Native Token Exchange is a somewhat different feature that many Authorization Server implementations provide, but satisfies a different use case). So, for long-running API requests, which can be found in API Gateway scenarios and the like, Refresh Tokens cached using database storage can provide an alternative solution to the token expiration challenge. Given thesecurity considerations to bear in mind however (below), attempting database Refresh Token caching is likewise not advised without a judicious security review. Again, further discussion is also beyond the scope of this article.
In its latest Auth for GenAI, the Auth0 Token Vault is an example of where database storage is being utilised for the caching of Refresh Tokens used by AI agents.
With any database integration, the potential for attack, via SQL Injection or similar, is always a possibility; where security tokens are concerned — particularly Refresh Tokens — the fallout from a successful attack could be potentially catastrophic! Given such consideration, in the majority of cases, token storage using a database is not generally advised for reasons of security. However, in cases where it is attempted, the preference should be to use a NoSQL database — like Redis, for example — which is less susceptible to injection attacks. Of course, there are other security implications to look at where database storage of tokens is being considered, but those are beyond the scope of this article.
Client Side Database
The W3C Index Database API (3.0) defines a specification for a database of records holding simple values and hierarchical objects; each consisting of a key-value pair, and where an (optional) query syntax can be layered on top. This specification is designed to address the case where user agents — i.e. public client Browsers — need to store large numbers of objects locally in order to satisfy off-line data requirements of Web applications. The term Webstorage, as it’s sometimes referred to, and also synonymous with HTML5, is a client-side database that is useful for many things, but not normally recommended for use as a token storage vehicle.
Storage At A Glance
I’ve attempted to summarise available token storage options based on application type and the recommended best practices. As I said at the beginning, SaaS solutions typically incorporate the likes of SPAs, regular Web Applications, Mobile Apps, and Backend services, too, so there are typically multiple scenarios, in multiple different contexts, in which token storage needs to be considered. Having discussed the various pros and cons in the preceding narrative, let’s take a bird’s-eye view of the best practice recommendations in a nutshell (you’ll probably want to rotate your device if viewing on a mobile 😁).
A SPA will typically enable you to reduce running costs as — outside of the initial hydration, or other BFF specific workflow — all of the application logic runs in the front end. Web server costs are typically kept to a minimum, so you will likely want to try not to compromise that when integrating a CIAM solution. Third-party SaaS CIAM vendor Curity provides a really good SPA Best Practices page, which is also worth a read.
Application Type | ID Token | Access Token | Refresh Token |
---|---|---|---|
SPA | In-Memory preferably;HttpOnly + Secure Cookie using a BFF, or Local Storage if persistence is required | In-Memory for security-sensitive operations;HttpOnly + Secure Cookie using a BFF, or Local Storage otherwise | HttpOnly + Secure Cookie utilising a BFF; Local Storage otherwise* |
Regular Web Application | In-Memory preferably;HttpOnly + Secure Cookie if persistence is required | In-Memory for security-sensitive operations;HttpOnly + Secure Cookie otherwise | HttpOnly + Secure Cookie* |
Native (i.e. Mobile or Desktop) | In-Memory preferably; Secure Storage if persistence is required | In-Memory for security-sensitive operations; Secure Storage otherwise | Secure Storage* |
* = Using single-use/rotation where possible
Advanced Security Measures
Whilst not specifically the province of this article, I felt it was worth mentioning a few of the additional security measures some of the more advanced SaaS CIAM solutions provide — Keycloak, being one in particular that springs to mind — which also have a bearing on tokens that may have been maliciously exfiltrated from storage. I won’t go into too much detail, but maybe I will come back to revisit some of these in future articles 😎:
- Token Binding: where a token is bound for use with a specific client or TLS session, thus preventing stolen tokens from being used elsewhere.
- Device Identification: where device IDs are stored against tokens to bind sessions to specific devices, helping anomaly detection and also facilitating conditional access.
- Anomaly Detection: to monitor for unusual token use (e.g., from different geographies or in rapid succession).
- Consent and Scope Enforcement: to ensure token scopes are minimised (think Principle of Least Privilege).
- Secure Logout: to ensure tokens are revoked on logout, storage is cleared, and sessions are ended at the IdP level to avoid silent (re)authentication.
Leave a Reply