The Ultimate Guide to JWT client side auth (Stop using local storage!!!)
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: ['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: ['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 || "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.