Hello, my name is Kati Frantz, and thank you so much for checking out this tutorial. I want to talk about how to handle JWTs effectively and securely on the client-side.
The most popular practice in the industry today is to save your JWT in a cookie or local storage. I've done this for a couple of years, and I have even taught others to do the same, but I didn't think it was a big deal until one of the applications I worked on was hacked.
This was an XSS
attack. This is an attack in which a malicious person runs malicious code on the client's browser directly attacking your application.
Now, they could do this to get access to local storage or cookies and extract the JWT from there.
These tokens used in sessions are usually long-lived, and the attackers can get access to your API for a very long time.
The solution we want to talk about today is one that would, first of all, prevent us from saving our tokens in a risky place, and secondly, implementing another solution that makes sure even if the attacker manages to get hold of a token, the access to the API would expire almost immediately.
Let's get started.
For this tutorial, the first thing we need is a real project. I have set up a sample project with user registration, login, and logout.
The /api
folder has a fully-featured graphql and auth server using just 20 lines of Tensei.js.
const { auth } = require('@tensei/auth')
const { tensei } = require('@tensei/core')
const { graphql } = require('@tensei/graphql')
tensei()
.plugins([
auth()
.user('Customer')
.plugin(),
graphql()
.middlewareOptions({
cors: {
credentials: true,
origin: ['http://localhost:3000']
}
})
.plugin()
])
.databaseConfig({
type: 'sqlite',
dbName: 'tensei.sqlite',
})
.start()
.catch(console.log)
The /client
folder is a React.js project generated with create react app. We have three routes: Login
, Register
, and Dashboard
.
User registration
When a user registers a new account, we make a request to the backend to get a JWT so we can automatically login the customer. At this point, this is usually when we'll set the JWT to local storage, but we won't be doing that. Here's the implementation of the register function:
client
.request(register, {
name: name.value,
email: email.value,
password: password.value,
})
.then(({ register_customer: { customer, token } }) => {
client.setHeader("authorization", `Bearer ${token}`);
setCustomer(customer);
history.push("/");
})
We do not set the token
to local storage, but we save it in memory. Here, we're setting it on the HTTP client so we can make subsequent authenticated requests to the API.
Next, we set the customer and redirect to the dashboard.
There's something very important that happens when we receive a response from the backend. Let's have a look at the backend response:
The backend set's an HttpOnly
cookie called ___refresh_token
on the response. This cookie has the unique property of not being accessible from the client-side. This means if you run document.cookie
in the developer console, you won't see the ___refresh_token
cookie.
This is because an HttpOnly
cookie can only be exchanged with the server, and cannot be accessed using client-side javascript.
Using this kind of cookie to set the refresh token gives us additional security, and assurance that the token can't fall into the wrong hands.
Understanding refresh tokens
The token we received in the JSON response from the API is an access token. This type of token gives the customer access to the API resources.
An access token should expire in about 10 to 15 minutes so that if it falls into the wrong hands, it becomes invalid as soon as possible.
A refresh token on the other hand does not give access. Instead, it can be used to request a new access token. That way, before the access token expires, you can silently request a new access token to keep your customers logged in.
Handling silent refresh
After registration, the customer is redirected to the dashboard, and they can access the dashboard because they are logged in. What happens when she refreshes the page or opens the app in a new tab?
Well, since we only set the token in memory, the customer loses access and is redirected to the sign-in page instead.
This is not pleasant, and we need to persist the customer's session somehow.
That's where a silent refresh comes in. Before actually redirecting the customer to the sign-in screen, we need to check if the user has an active session. We do this by calling the API to request a new access token.
A good place to do this is when the app mounts, showing a loading indicator to the user while we make this request:
const client = useClient();
const [customer, setCustomer] = useState(null);
const [working, setWorking] = useState(true);
const refreshToken = () => {
client
.request(refresh_token)
.then(({ refresh_token: { customer, token, expires_in } }) => {
client.setHeader("authorization", `Bearer ${token}`);
setCustomer(customer);
})
.catch(console.log)
.finally(() => {
setWorking(false);
});
};
useEffect(() => {
refreshToken();
}, [])
As soon as the app mounts, we make an HTTP request to the backend to refresh the access token. Since the ___refresh_token
is already set on the customer's browser, it is sent along with the request.
The backend gets the cookie, authenticates this cookie, and sends back a new access token with the customer's information.
We then set the token
on the HTTP client for subsequent requests and set the customer in the state. This means every time the customer visits the app, their session is fetched from the API and they are automatically logged in.
This solves the first problem, and the customer has a persistent session, but the access token will expire in 10 minutes, and we need to handle this case too.
The API also responds with how long the JWT takes to expire, so we can use this value to know when to silently call the API to get a new access token.
const client = useClient();
const [customer, setCustomer] = useState(null);
const [working, setWorking] = useState(true);
const refreshToken = () => {
client
.request(refresh_token)
.then(({ refresh_token: { customer, token, expires_in } }) => {
client.setHeader("authorization", `Bearer ${token}`);
setTimeout(() => {
refreshToken()
}, (expires_in * 1000) - 500)
setCustomer(customer);
})
.catch(console.log)
.finally(() => {
setWorking(false);
});
};
useEffect(() => {
refreshToken();
}, []);
We're using the expires_in
value to set a setTimeout
to refresh the token. This means a few milliseconds before the token expires, the refreshToken()
method is called again, and it'll set a new access token.
Great, we can now keep the customer always logged in with the access token only stored in memory.
Handling logout
What happens when the user needs to logout? We do not have access to the ___refresh_token
cookie from client-side javascript, so how do we clear it?
We need to call the API, and the API would invalidate the ___refresh_token
. On the dashboard page, when the logout
button is clicked, we'll invoke the following function:
const logout = () => {
client.request(remove_refresh_token).finally(() => {
history.push("/auth/signin");
setCustomer(null);
});
};
We call the remove_refresh_token
endpoint on the backend, and the response invalidates the ___refresh_token
cookie as such:
The backend response contains a Set-Cookie
header, which sets the Max-Age
of the ___refresh_token
header to 0
and its value to ''
, thus expiring it and making it invalid.
We then set the customer to null
and redirect to the sign-in page.
Cross domain considerations
In the example project, the client and server run on separate domains. This would most likely be the case for your application, and to allow two domains to exchange sensitive information with each other, you need to set some configuration on both client and server.
On the server, first, you need to enable CORS
, allowing the client domain to request resources from the server. Secondly, you need to allow the exchange of credentials. This informs the server to accept sensitive information such as cookies from the incoming client request. On our demo server, we configured this as such:
.middlewareOptions({
cors: {
credentials: true,
origin: ['http://localhost:3000']
}
})
Tensei.js uses apollo-server-express
behind the scenes for the graphql server, and this configuration is directly passed to it.
On the client, you need to configure your HTTP client such as Axios or Fetch to include sensitive credentials when making requests to an external API. In the demo project we used graphql-request
, which we configured as such:
import { GraphQLClient } from "graphql-request";
export default new GraphQLClient(
process.env.REACT_APP_API_URL || "http://localhost:4500/graphql",
{
credentials: "include",
}
)
Conclusion
When building applications that are not customer-facing, for tutorials or just fun projects, security might not be a big deal, but if working with real customer data, security has to be a top priority.
I highly recommend implementing a very secure JWT authentication system when building applications that would be used in the real world.
Please consider following me on Twitter and also checking out tensei.js and giving it a star.
Thank you very much for reading so far, and I hope this changes the way you handle JWT.
Top comments (22)
With all due respect, this is misinformed. Whether the token is in local storage, cookies, or in JavaScript memory, and whether you renew it frequently or not doesn't really change the actual issue.
The issue is that your application or website is vulnerable to an XSS attack.
Obscuring the location of your token (which you actually fail to do as you have a renovation token that can be used to generate tokens) does not solve the issue.
Shortening the lifespan of tokens does not solve the issue, it just gives attackers less time to access your account, which can be easily circumvented because they still have access to the renovation token.
The one and only issue here is XSS vulnerability. The rest are just security recommendations that could help, but a dedicated hacker still has a way in.
I'm worried people who read this post will think obscurity is the solution to this scenario. The only solution is fixing all XSS vectors in the app.
I don't really see that you are gaining anything by keeping the token in memory. The refresh token is already persisted. And if anything it seems like you are making things worse, but maybe I'm missing something.
So for example:
In this circumstance I would expect to have a valid session in both tabs, but I don't see how I could have.
If you are invoking the refresh token in the second tab, I would expect the token existing in memory in tab 1 to now be invalidated, in which case the page no longer works correctly. In the best case it will refresh the token again, but at this point you are basically reauthenticating a bunch of times if the user is jumping between tabs.
Again unless I'm missing something.
Also, to me this doesn't solve anything. You really had 2 issues and I only see one being solved here.
1. Tokens lasting forever or for a long time.
This should never be the case and tokens should always be provided with refresh token such that tokens are constantly refreshed.
But there isn't a good reason here for keeping the auth token in memory, I don't see that you gain anything keeping it in memory.
2. You site allowed an XSS attack
Really this is the point that needs to be addressed. I think your efforts would better be spent by investing in stronger Content Security Policy that better prevents XSS on the site.
Really if this isn't solved you haven't gained anything. I can still access the refresh token and hijack the session and I still have access to the token in memory.
So overall I guess, I don't see that you've solved anything by moving it to memory. You've just made the user experience worse.
Exactly this
The problem hits when you try doing this cross-domain (e.g. with the SPA statically served from myapp.com and the API from backend.com):
Hello, thanks for sharing. In my experience, even with the sample project I shared, the browser always attaches it when you correctly configure the HTTP client and the backend server.
Can you please share a scenario where this won't be the case ?
If you try to run your example in a setup where the domains are actually different (for cookie purposes, browsers don't count different ports as different domains see this RFC: stackoverflow.com/questions/161217...), it will fail. You have to enable 3rd party cookies to make it work, which comes disabled by default (in Safari at least and probably in all modern browsers)
Thanks for sharing this. The only scenario where it works seamlessly is in a situation where both sites run on the same domain (can be different subdomains, but must be the same domains).
This actually makes me very curious. How do third-party authentication providers persist sessions ? Take Auth0 for example, how do they persist sessions for the application they're authenticating, given its on a different domain?
Looking at the source code now for auth0-spa-js, and if you have a chance, please have a look. You might see what I can't see.
You're right, if you set the cookie with the right domain pattern (i.e.
.example.com
) it should work between subdomains, but would still fail between domains.One of the way you can use Auth0 is similar to what you've presented here, more or less, at least when it comes to third party cookies. Then again they don't recommend it (auth0.com/docs/login/embedded-logi...) and it doesn't work on many modern browsers (you can mitigate this by using a custom domain)
Also, it makes little sense to keep the bearer token in memory: whomever has access to the
localStorage
through XSS can scan thewindow
object as well.The
localStorage
andwindow
are globals. Is presumably easier to extract info from globals then from the encapsulated application logic code.Why does your refresh token look like a jwt?
Having api calls on separate domain doesn't work in case user blocks 3rd party cookies.
Your spa shouldn't do a set timeout, on every request, if the jwt has expired, it should refresh and set new jwt, this belongs in client side middleware.
Setting in cookie is good only if you know your api is being served under exactly same domain.
We do need to save jwt in cookie on client so that we can do server side rendering (else you don't have access to jwt in server side)
Hello, thanks for sharing.
First, the refresh token is a JWT, but it cannot be used to gain access to the API. It can only be used to get a new access token.
Secondly, you're right about 3rd party cookies. The ideal situation in this case would be when both sides of the application are running on the same domain.
Thirdly, there's really no disadvantage in my opinion to use a set time out at this level, its just some tiny background job that makes sure the backend never even needs to return a 401. Another approach like you mentioned is handling this in client side HTTP middleware. Some people prefer it this way, but I prefer not having to do a 401 check, requesting an access token, and making the request again when the user is waiting for an API call to resolve.
Also, This sample project works neatly across different domains, and I make a mention about that at the end of my article. Cookies can be exchanged cross domain, you just need to configure them.
And, for SSR, using something like Next.js for example, you can always either forward the cookie to the browser, or still handle it on Next.js own server without too much problems. Maybe I'll make a tutorial on this in future.
Thanks for sharing your ideas, I hope this makes sense to you.
A few things, I don't agree with:
When you say refresh token is a JWT but can't access, are you storing that in db? What is the expiry time? Because it must be enough long lived, say you did 3 months, and you're relying on JWT properties (signing key) to validate refresh token, then when I get access to your refresh token, how will you log me out? Unless you keep a list of blocklist of refresh token, (then how would you block those token anyway? Because you don't know, as you didn't save them), this means once I get a refresh token, you are duped. I suggest falling back to opaque tokens which reside in db, and you can show all the sessions in user's security page and you can simply revoke those refresh token, which means no more new JWT at least. (and it's more cheap to keep a blocklist of JWT with lower expiry time if you need instant signout)
I'm not saying middleware does a 401 check, if you write your middleware correctly it shouldn't return 401 because of expired JWT, here's a kinda rough middleware for tinka:
This way just before you make an API call, it ensures there's a valid JWT on request. (There's nothing wrong with having setTimeout but think about opening a tab in new browser, how many JWT do you want to actually store in db, a lot of people use multiple tabs and that'll invoke refresh token in each tab if we do setTimeout, but with this since it's done just before making a request, opening a new tab is okay.
"You just need to configure them." I don't think you understand what we meant. A lot of people are now blocking 3rd party cookies, I block 3rd party myself, which means, no matter what you do on your end, since I'm blocking 3rd party cookies, it simply won't work.
No, I mean I was agreeing with keeping tokens in cookie, but JWT must be accessible on client side, because if not, then how can I add authorization header for my API calls? If you want to say "don't use authorization header, just let API read those cookies", then I'm gonna stop even trying :)
also this:
This post is misinformed. The only thing that had to change about the situation is to stop being vulnerable to XSS. As long as there's a token, it can be stolen, no matter how many steps it takes from having the token to using it to access the account. Making those tokens short-lived is a good practice but doesn't change the facts.
I'm with you brother.
Let's face it, web traffic is compromised. There is no way around it. The most you can do is server-side restrictions, validation, and time-sensitive sessions. If you truly want to avoid some concerns, move away from REST and towards Websocket connections with one-off token grants that must be renewed before each connection. At the end of the day you need to perform cost-benefit analysis and gauge what solution fits best for the needs of the company and your application.
What can you accomplish one way that you cannot with the other? How convoluted is one solution when it could be much simpler? Is the security gain really that much more, so much so that it makes all other solutions obsolete?
When deciding on the right authentication protocol for your application, perhaps what you need is OAuth2 from the client to a 3rd party domain and do not persist the tokens at all, rather rely on the 3rd party domain to store that securely on their end (even though it may be another one of your apps). Putting up barriers without compromising the integrity of your application may be all that is needed.
I wonder - why won't you persist this JWT on a http-only cookie? given that you have the means to include this cookie for all subdomains and if another domain requires it, go ahead and set a cookie for it as well, after all it is your site's users sessions.
If you want to be safe then don't make your own auth. Cognito, Auth0, Keycloak, Firebase Auth all provide secure-auth that's easy to implement and practically free.
Dumb question: why do you need to tell the spa when the jwt expires? Presumably the spa has access to the public key which will allow access to the expiration on the token.
You don't, you don't even need pub key to know when it expires, client side doesn't need public key at all,
Use a middleware to do refresh token when it's expired, that way if the app is idle, it won't do a refresh
No dumb questions here Andrei, thanks for asking. This is just for convenience. You can tell the client when it expires, which means the client knows when to refresh, and does not wait for an unexpected expiry before refreshing. Like @nishchal mentioned, you can always wait for the backend to return a 401, and then either automatically logout the user and redirect to the sign in page, or refresh the token and keep the user's session.
I've been looking for something like this for quite some time now.
Keep up the good work!
Thanks a lot ! Glad you like it !