Protecting Routes and Data
Learning Objectives
- You know how to protect routes and data in a web application.
- You know how to use JWT middleware to protect routes.
- You know how to extract user information from a token.
At this moment, we have functionality for registering and authenticating, and we can show the user on the client. In addition, the server has the functionality for verifying the token.
In this chapter, we look into protecting routes and data, and using the Fetch API to access protected routes. We also look into how individual data would be stored and accessed.
Protecting routes
To verify that the user has the rights to perform the action they are trying to do, we need to authorize the user’s actions on the server. The first step is to decide what to protect — for now, we protect a route. The second step is defining the route so that when accessing it, we retrieve the token from the cookie sent by the user’s browser and validate it. If the token is valid, the user can proceed, while if the token is invalid, the user should not proceed.
JWT Middleware
As building token validation functionality directly to the function responsible for handling the route would create duplicate code (the functionality would have to be in every function that requires authentication), we use a middleware to handle the token validation. The middleware is added to the routes that require authentication, and the middleware is responsible for checking the user’s credentials.
Hono comes with a JWT Middleware that can be used for this purpose — the middleware is available through the same import that we previously used to import the JWT helper functionality.
import * as jwt from "jsr:@hono/hono@4.6.5/jwt";
The middleware is a function called jwt
, which is passed to the app.use
function. The jwt
function takes an object as a parameter, and the object should contain the JWT secret, and the name of the cookie in which the token is stored.
The secret should be the same as the secret used to sign the JWTs.
Creating and securing an API endpoint
Let’s create an API endpoint that requires authentication — let’s call the endpoint /api/notes
, and have the endpoint return a list of notes (for now, the endpoint returns a static list; we soon modify the endpoint to return notes from the database). The endpoint is created as follows.
app.get("/api/notes", async (c) => {
return c.json(["C", "D", "E", "F", "G", "A", "B"]);
});
By default, the above endpoint can be accessed by anyone.
curl -v localhost:8000/api/notes
> GET /api/notes HTTP/1.1
// ...
< HTTP/1.1 200 OK
// ...
["C","D","E","F","G","A","B"]%
To check whether the user is authenticated, we can add the JWT Middleware to the route.
// ...
import * as jwt from "jsr:@hono/hono@4.6.5/jwt";
// ...
const COOKIE_KEY = "auth";
const JWT_SECRET = "secret";
app.use(
"/api/notes",
jwt.jwt({
cookie: COOKIE_KEY,
secret: JWT_SECRET,
}),
);
app.get("/api/notes", async (c) => {
return c.json(["C", "D", "E", "F", "G", "A", "B"]);
});
// ...
Now, when we try out the API using curl, we see that the API returns the 401 status code and the string “Unauthorized”.
curl -v localhost:8000/api/notes
> GET /api/notes HTTP/1.1
// ...
< HTTP/1.1 401 Unauthorized
// ...
Unauthorized%
Accessing a secured API endpoint from client
Next, create a folder called notes
to the src/routes
folder, and create a file called +page.svelte
to the folder. Add the following content to the file.
<script>
import { PUBLIC_API_URL } from "$env/static/public";
let notes = $state([]);
let error = $state("");
const fetchNotes = async () => {
error = "";
const response = await fetch(`${PUBLIC_API_URL}/api/notes`, {
credentials: "include",
});
if (!response.ok) {
error = "Failed to fetch notes.";
return;
}
notes = await response.json();
};
</script>
<button class="btn preset-filled-primary-500" onclick={fetchNotes}
>Fetch notes</button
>
<h2 class="text-xl">Notes</h2>
{#if error}
<p class="text-xl">{error}</p>
{/if}
<ul>
{#each notes as note}
<li>{note}</li>
{/each}
</ul>
The above component fetches the notes from the API and shows them on the page. The API URL is fetched from the environment variables, and the notes are fetched when the button is clicked. The component should be fairly familiar, with one minor difference — the options for the fetch
function include a property credentials
, which is set to include
. The include
value tells the browser to include the cookies in the request.
For additional details, see Including credentials part of Fetch API documentation on MDN.
Now, when we login to the application, visit the path /notes, and click the “Fetch notes” button, the notes are shown on the page, as shown in Figure 1.
Protecting data
Protecting data involves protecting the route that returns the data, but also protecting the data itself. The route is protected by checking the user’s credentials, and the data is protected by checking the user’s credentials when the data is accessed.
For a concrete example, create a migration file V7__user_notes.sql
, and place it to the database-migrations
folder of the walking skeleton. You may also use another version number to match your database migraton files. Add the following content to the file.
CREATE TABLE notes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
text TEXT NOT NULL
);
The above creates a database table notes
that has a foreign key user_id
that references the id
column of the users
table. The table also has a column text
that stores the note text.
Extracting user information from the token
Protecting data involves checking whether the user has the rights to access the data. In the case of the notes table, this could involve checking whether the user identifier in the token matches the user identifier in the notes table.
For convenience, we would create another middleware that would extract the user from the token and add it to the context. This would look as follows.
const userMiddleware = async (c, next) => {
const token = getCookie(c, COOKIE_KEY);
const { payload } = jwt.decode(token, JWT_SECRET);
c.user = payload;
await next();
};
The above middleware adds a variable user
to the context variable c
of Hono. The variable can be used to identify the user when accessing the notes.
The middleware that extracts the user fromt he token would be placed after the middleware that validates the token. This way, the user is only extracted if the token is valid.
const COOKIE_KEY = "auth";
const JWT_SECRET = "secret";
const userMiddleware = async (c, next) => {
const token = getCookie(c, COOKIE_KEY);
const { payload } = jwt.decode(token, JWT_SECRET);
c.user = payload;
await next();
};
// first, verify that the token is valid
app.use(
"/api/notes",
jwt.jwt({
cookie: COOKIE_KEY,
secret: JWT_SECRET,
}),
);
// then, extract the user identifier from the token
app.use("/api/notes", userMiddleware);
app.get("/api/notes", async (c) => {
return c.json(["C", "D", "E", "F", "G", "A", "B"]);
});
Fetching and showing user-specific notes
Now, as requests have the user and the user identifier in the context variable, we can use the user identifier in database queries. This allows modifying the route that returns the notes so that we query the database for the notes that belong to the user.
app.get("/api/notes", async (c) => {
const notes = await sql`SELECT * FROM notes WHERE user_id = ${c.user.id}`;
return c.json(notes);
});
As previously, we were just listing items, we need to also modify the +page.svelte
file in src/routes/notes
to show the notes from the database, where each note is stored as an object. The change would focus on iterating over the notes, where we would show the text from the note instead of the note, as follows.
<!-- ... -->
<ul>
{#each notes as note}
<li>{note.text}</li>
{/each}
</ul>
Adding notes
Let’s continue with the example and add the functionality for adding a note. Add the following route to the server.
app.post("/api/notes", async (c) => {
const { text } = await c.req.json();
const result = await sql`INSERT INTO notes (user_id, text)
VALUES (${c.user.id}, ${text}) RETURNING *`;
return c.json(result[0]);
});
The above route adds a note to the database. The note is added to the database with the user identifier from the token, and the text of the note is taken from the request body.
Next, modify the +page.svelte
file in src/routes/notes
to add an input field for adding a note. After the change, the component would look as follows.
<script>
import { PUBLIC_API_URL } from "$env/static/public";
let notes = $state([]);
let error = $state("");
let text = $state("");
const fetchNotes = async () => {
error = "";
const response = await fetch(`${PUBLIC_API_URL}/api/notes`, {
credentials: "include",
});
if (!response.ok) {
error = "Failed to fetch notes.";
return;
}
notes = await response.json();
};
const addNote = async () => {
error = "";
const response = await fetch(`${PUBLIC_API_URL}/api/notes`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ text }),
});
if (!response.ok) {
error = "Failed to add note.";
return;
}
text = "";
fetchNotes();
};
</script>
<button class="btn preset-filled-primary-500" onclick={fetchNotes}
>Fetch notes</button
>
<h2 class="text-xl">Notes</h2>
{#if error}
<p class="text-xl">{error}</p>
{/if}
<ul>
{#each notes as note}
<li>{note.text}</li>
{/each}
<li class="flex items-center">
<input class="input" type="text" bind:value={text} />
<button
type="button"
class="btn ml-2 preset-filled-primary-500"
onclick={addNote}>Add note</button
>
</li>
</ul>
Now, we can add notes to the database by typing the note text to the input field and clicking the “Add note” button. The notes are shown on the page, and the notes are stored in the database. In addition, when storing notes, they are stored with the user identifier from the token, so that the notes are user-specific.
While the example here shows a situation where all the functionality is placed in the same component, we would normally refactor the code to e.g. have a separate file for accessing the API and so on.
Protecting individual data
Web applications often have routes that correspond to individual resources, and the resources are often user-specific. Let’s expand on the previous example and add functionality for showing individual notes. The functionality would be such that the user can click on a note in the list of notes, which links to another page where the note is shown.
To show individual notes, we first add a route on the server that fetches an individual note based on the path variable and the authenticated user.
app.get("/api/notes/:id", async (c) => {
const notes = await sql`SELECT * FROM notes
WHERE id = ${c.req.param("id")} AND user_id = ${c.user.id}`;
if (notes.length <= 0) {
c.status(404);
return c.json({ error: "Note not found" });
}
return c.json(notes[0]);
});
Then, we create a folder [note]
to the src/routes/notes
folder, and create the files +page.js
and +page.svelte
to the folder. Add the following to +page.js
.
export const load = ({ params }) => {
return params;
};
And then, add the following content to +page.svelte
.
<script>
import { PUBLIC_API_URL } from "$env/static/public";
let { data } = $props();
let note = $state({});
let error = $state("");
const fetchNote = async () => {
const response = await fetch(`${PUBLIC_API_URL}/api/notes/${data.note}`, {
credentials: "include",
});
if (!response.ok) {
error = "Failed to fetch note.";
return;
}
note = await response.json();
};
$effect(() => {
fetchNote();
});
</script>
<p>Viewing note with identifier {data.note}</p>
{#if error && error.length > 0}
<p class="text-xl">${error}</p>
{/if}
<p>{note.text}</p>
We’re basically doing dynamic pages again.
Finally, modify the +page.svelte
file in src/routes/notes
to link to the individual note pages. The key change would be at the end of the component, as follows.
<!-- ... -->
<ul>
{#each notes as note}
<li><a href="/notes/{note.id}">{note.text}</a></li>
{/each}
<li class="flex items-center">
<input class="input" type="text" bind:value={text} />
<button
type="button"
class="btn ml-2 preset-filled-primary-500"
onclick={addNote}>Add note</button
>
</li>
</ul>
Almost everything is in place, but the implementation does not yet work.
At the moment, the JWT middleware is only used for the /api/notes
route, and the user identifier is only extracted for the /api/notes
route. To protect the individual notes, we need to modify the middlewares to catch all paths related to notes. This is done by modifying the middleware paths to /api/notes/*
.
app.use(
"/api/notes/*",
jwt.jwt({
cookie: COOKIE_KEY,
secret: JWT_SECRET,
}),
);
app.use("/api/notes/*", userMiddleware);
Now, when we try to access the notes (or any individual note) without authenticating, the API returns the status code 401. On the other hand, when we try to access the notes as an authenticated user, we can only access the notes that belong to the user.