JSON Web Tokens
Learning objectives
- You know what JSON Web Tokens are.
- You know how to use JSON Web Tokens on the server.
- You know how to use JSON Web Tokens on the client.
JSON Web Token (JWT) is a way to transmit information between two parties, often used in authentication and authorization. A JWT consists of three parts: a header, a payload, and a signature. The header and payload are JSON objects that are base64 encoded. In particular, a JWT contains claims, which are statements about an entity (typically, the user) -- the claims are included in the payload. The signature is created by hashing the header and payload with a secret key, which is only known to the server. Using the secret key, the server can then verify that the token is valid by hashing the header and payload with the secret key and comparing the result with the signature.
The underlying principle is similar to Signed Cookies, where the cookie data is signed with a key known only to the server, and the server can verify that the cookie data has not been tampered with.
JWT on the server
The server is responsible for creating and verifying the tokens. When using Hono, we utilize the JWT Authentication Helper for creating the tokens and the JWT Auth Middleware for verifying the token and for making it easier to access the payload on the server.
An implementation with a JWT middleware and signing of the token payload would look as follows.
import { Hono } from "https://deno.land/x/hono@v3.12.11/mod.ts";
import { cors, jwt } from "https://deno.land/x/hono@v3.12.11/middleware.ts";
import { sign } from "https://deno.land/x/hono@v3.12.11/utils/jwt/jwt.ts";
const jwtSecretKey = "secret";
const app = new Hono();
app.use("*", cors());
app.use("/api/secret/*", jwt({ secret: jwtSecretKey }));
app.get("/api/secret/me", async (c) => {
const payload = c.get("jwtPayload");
console.log("User making request:");
console.log(payload);
const secret = {
message: "Found it!"
};
return c.json(secret);
});
app.post("/api/auth", async (c) => {
const body = await c.req.json();
if (body.password !== "Alohomora") {
return c.json({ error: "Invalid password." }, 400);
}
const user = {
id: 1,
username: "Harry Potter",
};
const signedToken = await sign(user, jwtSecretKey);
c.header("Access-Control-Expose-Headers", "*");
c.header("Authorization", `Bearer ${signedToken}`);
return c.json(user);
});
Deno.serve(app.fetch);
Above, we have two endpoints: /api/auth
for logging in and /api/secret/me
for accessing a secret resource (the endpoint also reads the payload from the request, giving the endpoint access to the user information). The /api/secret/me
endpoint is protected by the JWT middleware, which means that the user must be logged in to access it. The /api/auth
endpoint is responsible for logging in the user. When the user is logged in, we sign the user object with the secret key and return it to the client. The client can then use the token to access the protected resource.
The header
Access-Control-Expose-Headers
is used to expose theAuthorization
header to the client. Without this header, the client would not be able to access theAuthorization
header, and thus not be able to use the token.
When using the server on the command-line, we see that it works as expected. First, when login is successful, we receive a response with the Authorization
header set to the signed token. Then, when we try to access the protected resource, we receive a response with the secret message only when the Authorization header is correct.
curl -X POST -d '{"password":"Alomomora"}' localhost:8000/api/auth
{"error":"Invalid password."}
curl --verbose -X POST -d '{"password":"Alohomora"}' localhost:8000/api/auth
...
< authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJIYXJyeSBQb3R0ZXIifQ.rsjeBSezjW1tqbGMaIsP4qL6THi4JV7TyW0axrAd940
...
{"id":1,"username":"Harry Potter"}
curl -X POST -d '{"password":"Alomomora"}' localhost:8000/api/secret/me
Unauthorized
curl -X POST -H 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJIYXJyeSBQb3R0ZXIifQ.rsjeBSezjW1tqbGMaIsP4qL6THi4JV7TyW0axrAd940' localhost:8000/api/secret/me
Unauthorized
curl -X POST -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJIYXJyeSBQb3R0ZXIifQ.rsjeBSezjW1tqbGMaIsP4qL6THi4JV7TyW0axrAd940' localhost:8000/api/secret/me
{"id":1,"username":"Harry Potter","message":"Correct credentials!"}
JWT on the client
On the client, JWT is used to store information about the user. When the user logs in, the server returns a signed token, which is stored in the browser. The token is then sent to the server with every request, and the server can verify that the token is valid and extract the user information from the token.
A naive version that does not store the token to the server could look as follows.
<script>
let authorization = $state(null);
let user = $state(null);
const authenticate = async () => {
const response = await fetch("http://localhost:8000/api/auth", {
method: "POST",
body: JSON.stringify({ password: "Alohomora" })
});
if (response.status !== 200) {
alert("Something went wrong!");
return;
}
authorization = response.headers.get("Authorization");
user = await response.json();
};
const secretAction = async () => {
const response = await fetch("http://localhost:8000/api/secret/me", {
headers: {
Authorization: authorization
}
});
if (response.status !== 200) {
alert("Something went wrong!");
return;
}
const data = await response.json();
alert(data.message);
};
</script>
<button class="btn btn-primary" on:click={authenticate}>Authenticate!</button>
{#if user?.username}
<p>Authenticated!</p>
<p>User: {user.username}</p>
<p>Authorization: {authorization}</p>
<button class="btn btn-primary" on:click={secretAction}>Do secret action!</button>
{:else}
<p>Not authenticated!</p>
{/if}
In the above example, we have two functions: authenticate
and secretAction
. The authenticate
function sends a request to the server to log in the user. If the login is successful, the Authorization
header from the response is stored in the application state, similar to the user details in the response. The secretAction
function sends a request to the server to access the protected resource. If the request is successful, the secret message is displayed.
In practice, as discussed in the part Remembering State in Client of the chapter State Management, we would store the token in the browser's local storage. This way, the token is not lost when the page is reloaded.
When we run the application, we see that it works as expected. First, when we click the Authenticate!
button, we receive a response with the Authorization
header set to the signed token. Then, when we click the Do secret action!
button, we receive a response with the secret message only when the Authorization header is correct.
In addition, we would separate the API functionality from the UI functionality, e.g. as discussed in the part on Separating API Functionality of the chapter Interacting with APIs. It would be also possible to create an abstraction and rely on Client-Side API Endpoints that SvelteKit provides -- we do not go deeper into this.