Cookies and JWTs
Learning Objectives
- You know how to use JSON Web Tokens (JWTs) as cookie values.
- You know how to decode JWTs on the client.
- You know how to verify JWTs on the server.
- You know how to set expiration times for JWTs and how to refresh the tokens.
In the previous chapter, we built functionality where the user is tracked using cookies, and where the user was shown the user id from the cookie. There was a small problem though, as the cookie was a plaintext plaintext cookie, anyone could modify the cookie value without it being invalidated.
Here, we build on the functionality from the last chapter, using JSON Web Tokens (JWTs) as cookie values instead.
JWTs on the server
Similar to cookies, Hono comes with JWT Helpers that make it easier to work with JWTs on the server. To use the helpers, we import the functions exposed from jsr:@hono/hono@4.6.5/jwt
, giving them the alias jwt
.
import * as jwt from "jsr:@hono/hono@4.6.5/jwt";
There are three functions, decode
, sign
, and verify
, that can be used to decode, sign, and verify JWTs, respectively. The function sign creates a JWT token from an object with the payload — i.e., the content to sign — and a secret key, while the verify function verifies a given token using the secret key. The decode function can be used to decode the token without verifying it.
To use JWTs on the server, we need to store the secret key in a secure way. In this example, we store the secret key in a constant JWT_SECRET
— in practice though, the secret would be an environment variable.
The following example outlines the key changes to the API endpoint for authenticating the user. Now, instead of directly setting the user identifier from the database as the cookie value, we create a payload that we sign that contains the id of the user, producing a token. The token, then, is set as the cookie value.
// ...
import { getCookie, setCookie } from "jsr:@hono/hono@4.6.5/cookie";
import * as jwt from "jsr:@hono/hono@4.6.5/jwt";
// ...
const COOKIE_KEY = "auth";
const JWT_SECRET = "secret";
// ...
app.post("/api/auth/login", async (c) => {
// ...
if (passwordValid) {
// define the payload
const payload = {
id: user.id,
};
// create the token by signing the payload
const token = await jwt.sign(payload, JWT_SECRET);
// set the token as the cookie value
setCookie(c, COOKIE_KEY, token, {
path: "/",
domain: "localhost",
httpOnly: true,
sameSite: "lax"
});
return c.json({
"message": `Logged in as user with id ${user.id}`,
});
} else {
return c.json({
"message": "Invalid password!",
});
}
Now, when we try authenticating, the response has a cookie with the JWT token as the value.
curl -v -X POST -d '{"email": "test@test.com", "password": "secret"}' localhost:8000/api/auth/login
// ...
> POST /api/auth/login HTTP/1.1
// ...
< HTTP/1.1 200 OK
< access-control-allow-credentials: true
< set-cookie: auth=(the jwt token); Domain=localhost; Path=/; HttpOnly; SameSite=Lax
// ...
{"message":"Logged in as user with id 1"}%
JWTs on the client
The next step is to handle the JWT token on the client. The functionality that we built in the last chapter to the login form action remains the same. That is, in the part in +page.server.js
that corresponds to the handling the login form, we extract the cookie from the response from the authentication API and set it as a cookie on the client.
// ...
const COOKIE_KEY = "auth";
// ...
if (response.ok) {
const responseCookies = response.headers.getSetCookie();
const cookie = responseCookies.find((cookie) =>
cookie.startsWith(COOKIE_KEY),
);
const cookieValue = cookie.split("=")[1].split(";")[0];
cookies.set(COOKIE_KEY, cookieValue, { path: "/", secure: false });
throw redirect(302, "/");
}
return response;
// ...
There is no need to change +page.server.js
as the only thing that changes is the value of the cookie.
To show the user identifier in the application, we need to decode the JWT token on the client. To do this, we install a library that can handle JWTs — here, we use the jose library.
In the folder client that contains the client-side application, run the command.
deno install npm:jose
This installs the jose library to the server responsible for the client-side application.
Now, we modify the hooks.server.js
file to decode the JWT token from the cookie and set decoded payload to the user property of the locals object. To do this, we import the decodeJwt
function from the jose library, decode the token from the cookie, and set the value of the event.locals.user
variable to the decoded payload.
After the change, the hooks.server.js
looks as follows.
import { decodeJwt } from "jose";
const COOKIE_KEY = "auth";
export const handle = async ({ event, resolve }) => {
const authCookie = event.cookies.get(COOKIE_KEY);
if (authCookie) {
try {
const payload = decodeJwt(authCookie);
event.locals.user = payload;
} catch (e) {
console.log(e);
}
}
return await resolve(event);
};
Now, the payload is extracted from the JWT token instead of using the the plain-text cookie value as we did before.
The last step is to show the user identifier in the application. Now, the user is an object instead of a variable, and we need to access the user identifier from the object. The following example outlines how to do this in the +layout.svelte
file.
<script>
import { useUserState } from "$lib/states/userState.svelte.js";
import "../app.css";
let { children, data } = $props();
const userState = useUserState();
if (data.user) {
userState.user = data.user;
}
</script>
{#if data.user?.id}
<p>Hello {data.user?.id}!</p>
{/if}
<main class="container mx-auto max-w-lg">
{@render children()}
</main>
As the +layout.server.js
just passes the locals to the layout, there is no need to change the file.
Now, restarting the application and logging in, we see that the user identifier is shown in the application as before (see Fig. 1). The difference is that now the user identifier is extracted from the JWT token instead of the plain-text cookie.
The payload of the JWT token can naturally contain also other information than the user identifier. For example, the payload could contain the user’s name, email, or other information such as role that could be needed in the application.
Verifying JWTs
The last step is to add the functionality for verifying the JWT token on the server, which means asking the server whether the token is valid on each request. To do so, we modify the authentication API to provide an endpoint called /api/auth/verify
that extracts the token from the request and verifies it.
The API endpoint can be created as follows.
// ...
app.post("/api/auth/verify", async (c) => {
const token = getCookie(c, COOKIE_KEY);
if (!token) {
c.status(401);
return c.json({
"message": "No token found!",
});
}
try {
await jwt.verify(token, JWT_SECRET);
return c.json({
"message": "Valid token!",
});
} catch (e) {
c.status(401);
return c.json({
"message": "Invalid token!",
});
}
});
// ...
The example above outlines an endpoint that first checks whether a token exists in the request, and then if yes, verifies the token. If the token does not exist or the token is invalid, the endpoint returns a 401 status code and a message. Otherwise, the response indicates that the token is valid.
To send a request to the endpoint to verify the token on each request, we modify the hooks.server.js
file. In the file, if the cookie exists, we make a fetch request to the /api/auth/verify
endpoint to verify the token, setting the cookie as a header in the request. If the token is not valid, we clear the cookie, while otherwise we set the payload as the value of the user
property of the locals object.
This would be implemented in the hooks.server.js
as follows.
import { PUBLIC_INTERNAL_API_URL } from "$env/static/public";
import { decodeJwt } from "jose";
const COOKIE_KEY = "auth";
export const handle = async ({ event, resolve }) => {
const authCookie = event.cookies.get(COOKIE_KEY);
if (authCookie) {
const response = await fetch(`${PUBLIC_INTERNAL_API_URL}/api/auth/verify`, {
method: "POST",
headers: {
"cookie": `${COOKIE_KEY}=${authCookie}`,
},
});
// response not ok, clear cookie
if (!response.ok) {
event.cookies.delete(COOKIE_KEY, { path: "/" });
return await resolve(event);
}
try {
const payload = decodeJwt(authCookie);
event.locals.user = payload;
} catch (e) {
console.log(e);
}
}
return await resolve(event);
};
Now, our application verifies the token on each request, and if the token is invalid, the cookie is cleared. If the token is valid, the user identifier is set to the locals object. The above hook could also, for example, redirect the user to another location if the token is invalid.
Expiration and Refreshing
The JWT token can be set to expire after a certain time. The expiration is set as the exp
property of the payload. As an example, if we would wish to create a token that expires in 60 seconds, we could set the exp
property to the current time plus 60 seconds.
const payload = {
id: user.id,
exp: Math.floor(Date.now() / 1000) + 60,
};
With the above, the token would be valid for 60 seconds after it was created. After that, the token would be invalid. You may try the above out by logging in to a locally running application, waiting for 60 seconds, and attempting to reload the page.
The verification functionality on the server could also be used to refresh the token. That is, if the token is valid, the server could return a new token with a new expiration time. The following example shows how this would be done on the server.
// ...
app.post("/api/auth/verify", async (c) => {
const token = getCookie(c, COOKIE_KEY);
if (!token) {
c.status(401);
return c.json({
"message": "No token found!",
});
}
try {
const payload = await jwt.verify(token, JWT_SECRET);
payload.exp = Math.floor(Date.now() / 1000) + 60;
const refreshedToken = await jwt.sign(payload, JWT_SECRET);
setCookie(c, COOKIE_KEY, refreshedToken, {
path: "/",
domain: "localhost",
httpOnly: true,
sameSite: "lax",
});
return c.json({
"message": "Valid token!",
});
} catch (e) {
c.status(401);
return c.json({
"message": "Invalid token!",
});
}
});
// ...
Now, the hook on the client could be modified to handle the new token. That is, if the token is valid, the client could set the new token as the cookie value.
import { PUBLIC_INTERNAL_API_URL } from "$env/static/public";
import { decodeJwt } from "jose";
const COOKIE_KEY = "auth";
export const handle = async ({ event, resolve }) => {
const authCookie = event.cookies.get(COOKIE_KEY);
if (authCookie) {
const response = await fetch(`${PUBLIC_INTERNAL_API_URL}/api/auth/verify`, {
method: "POST",
headers: {
"cookie": `${COOKIE_KEY}=${authCookie}`,
},
});
// response not ok, clear cookie
if (!response.ok) {
event.cookies.delete(COOKIE_KEY, { path: "/" });
return await resolve(event);
}
// get cookies from response headers and set the cookie
const responseCookies = response.headers.getSetCookie();
const cookie = responseCookies.find((cookie) =>
cookie.startsWith(COOKIE_KEY)
);
// if no cookie, no need to extract it -- just resolve
if (!cookie) {
return await resolve(event);
}
const cookieValue = cookie.split("=")[1].split(";")[0];
event.cookies.set(COOKIE_KEY, cookieValue, { path: "/", secure: false });
try {
const payload = decodeJwt(authCookie);
event.locals.user = payload;
} catch (e) {
console.log(e);
}
}
return await resolve(event);
};
Now, the token would be refreshed on each request, and the user would be able to use the application as long as the token is valid.
In the above examples, the expiration time has been set to 60 seconds. The time has been set to be short to make testing the functionality easier. In production, the expiration time should be longer, e.g. from one day to two weeks, depending on security and usability requirements of the application.