Secure JWT Tokens + Cypress

In this writeup, we’ll cover how to securely store JWT tokens to help mitigate against attackers and setting things up in a way that allows for easy testing with Cypress.

Using dj-rest-auth, we’ll get two tokens upon logging in: an access token and a refresh token. The access token is used to authenticate all API requests and it is short-lived, whereas the refresh token is longer lasting and is only used to get a new access token after it expires.

But Why?

Cool, so we use the access token until it expires, then use the refresh token to get a new access token. Sounds easy, why the writeup? The issue is with how people typically handle these tokens. If your experience is anything like mine, you’ll see most writups recommending placing the tokens in localStorage or maybe using cookies, if they’re trying to be fancy.

The problem with those options is that you set them via JS, which means JS has access to those locations. This allows a malicious script to steal the tokens very easily. This is especially bad with regards to the refresh token, since the attacker can use that to continue getting new access tokens, giving them prolonged access to the victim’s account.

The solution? HttpOnly cookies.

From https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies/

A cookie with the HttpOnly attribute is inaccessible to the JavaScript [Document.cookie](<https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie>) API; it's only sent to the server. For example, cookies that persist in server-side sessions don't need to be available to JavaScript and should have the HttpOnly attribute. This precaution helps mitigate cross-site scripting ([XSS](<https://developer.mozilla.org/en-US/docs/Web/Security/Types_of_attacks#cross-site_scripting_(xss)>)) attacks.

By using these in combination with localStorage, we can achieve a good balance between user experience, developer experience and security. We can place the short-lived access token in localStorage, where it’s easy to access but due to its short lifespan, the risk is minimal. It persists, giving the user the ability to leave the page and return before the token expires without any interruption in behaviour. We store the refresh token as an HttpOnly cookie so it’s still saved, but is inaccessible via JS.

Django Configuration

An example repo can be found at https://github.com/JGTechnologies/cypress_auth, which I will be referencing throughout the rest of this writeup.

Down towards the bottom of the [settings.py](<http://settings.py>) file (https://github.com/JGTechnologies/cypress_auth/blob/main/api/api/settings.py#L142), we can configure a few important settings such as the token lifetimes (how long each one lasts before it expires), as well as the names of the tokens, if you care to adjust those. I’ve set the access token to expire after 15 minutes and the refresh token lasts for 1 full day, at which time the user will need to log in again to get a new token pair. I find this to be reasonable in most cases, but feel to adjust it per your project requirements.

Tokens in Action

Moving our attention to the frontend LoginForm component (https://github.com/JGTechnologies/cypress_auth/blob/main/frontend/src/components/LogInForm.vue) we see the onSubmitted method calls the login util method. You may notice that we’re only keeping the access token and user id in the auth store.

dj-rest-auth takes care of setting up the HttpOnly cookie for us. If you need it for anything, the refresh token is sent back to us in the login response:

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNjY2OTgxNTQxLCJpYXQiOjE2NjY5ODA2NDEsImp0aSI6ImZlMmM3Y2Q2ZjQ3MTQyZmJiOTkwMTY3MzU3YmQ1MjUwIiwidXNlcl9pZCI6M30.7_kpVl4GQsWCyu1wwcA_yZzukJ-Knr5GcgjkorVn6YM","refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTY2NzA2NzA0MSwiaWF0IjoxNjY2OTgwNjQxLCJqdGkiOiI2MmMzM2UwZGFmODM0NmZjYTU2YWRlOTA1M2JkMjU1ZSIsInVzZXJfaWQiOjN9.RosUe5jKCVU0uFBJXZZG5DE3dTfVKWE0HnNCrbB_x2w","user":{"pk":3,"username":"Cypress-Test-User","email":"email@domain.com","first_name":"","last_name":""}}

If we take a look at the response headers, we can see dj-rest-auth is setting the HttpOnly cookie:

Sending the Refresh Cookie

When we click the button on the home page, it will make a request to the auth/user/ endpoint. We can check the network tab of our devtools to confirm the refresh token cookie is being sent with the request.

Notice that it is sending the jwt-refresh cookie, even though we never set this up on the JS side. This is exactly what we wanted! JS doesn’t know about the cookie and is unable to access it, but it gets sent with each request automatically. Because of this, we can use an axios interceptor to listen for 401 responses and request a new access token, providing a smooth experience for the user. Waiting out the 15 minute lifetime of the access token and hitting the button again, we see the interceptor work its magic.

The call to the user/ endpoint returned a 401, so the interceptor sent an OPTIONS request and then a POST request to the refresh/ endpoint, which sent back a new access token. That token is saved and the original request is re-sent, this time with the updated token and the user is none the wiser. We’ve successfully secured the refresh token away from JS without negatively impacting our users!

Adding Cypress

Tying this in with Cypress is also very easy. Best practice is to not log in via the login form when running tests. There are several reasons for this, the two main reasons being speed and isolation. If something goes wrong with the login form, it will break all tests, even though they aren’t testing the form. It’s also significantly faster to log in programmatically, rather than navigating to the login page, filling out the form, waiting for the response and then going to the page you actually want to test. So how do we accomplish this?

We can set up a cypress command to handle logging us in and out (https://github.com/JGTechnologies/cypress_auth/blob/main/frontend/cypress/support/commands.ts).

That’s all there is to it. Making a POST request to the auth/login/ endpoint and saving the access-token and user-id localStorage values is all we need to do. To log out, we can remove those items from localStorage. Since the auth store will pull from localStorage if nothing is in the state, this allows us to pretend like the state is set without actually having to set it.

In your future tests you can now call the login command like so: cy.login('myemail', 'mypassword') which will set the localStorage values. As long as you remember to write the getters with fallbacks that check localStorage, Cypress will be able to interact with your app as if you’d logged in through the form.

More from Lofty