Users and Security

Authorization and Access Control


Learning Objectives

  • You know how to protect routes from unauthorized access.
  • You know how to make authenticated requests from the client.

At this moment, we have functionality for registering and authenticating users, and we can show the user information on the client. In addition, the server has the functionality for creating and verifying JWT tokens. However, we haven’t yet protected any of our server routes or data.

In this chapter, we look into protecting routes and data, and using authentication to control access to resources. This chapter focuses on protecting a simple route, while in the next chapter, we’ll look into protecting user-specific data.

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. Authorization must always happen on the server: client-side checks are only for user experience, as they can be easily bypassed.

When protecting routes, the first step is to decide what to protect and the second step is defining the route so that when accessing it, we can verify whether the user is authorized to access it. This typically involves using middleware to inspect the request for information such as an authentication token, and then validating the request based on the token.

Loading Exercise...

Secret endpoint

Let’s start with a simple example. Create an API endpoint that returns a secret message.

Add the following route to your app.js:

app.get("/api/secret", (c) => {
  return c.json({ message: "This is a secret message!" });
});

By default, this endpoint can be accessed by anyone without authentication:

$ curl localhost:8000/api/secret
{"message":"This is a secret message!"}

This is not secure. We want that the endpoint can be accessed only by authenticated users.

Authentication middleware

When using JWT, checking whether a user is authenticated involves validating the token that the user sends with their request. As this would be done for all routes that require authentication, it’s best to implement this as middleware.

Let’s create a middleware function that checks for a valid token. The function will work as follows:

  1. Extract the token from the Authorization header
  2. Verify the token
  3. Extract user information from the token
  4. Add user information from the token to the context

Create a file called middlewares.js to the server-side application, and place the following code to the file.

import * as jwt from "@hono/hono/jwt";

const JWT_SECRET = "jwt_secret";

const authenticate = async (c, next) => {
  const authHeader = c.req.header("Authorization");

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return c.json({ error: "Missing or invalid authorization header" }, 401);
  }

  // Drop the "Bearer " prefix to get the token
  const token = authHeader.substring(7);

  try {
    const payload = await jwt.verify(token, JWT_SECRET);
    c.set("user", payload);
    await next();
  } catch (error) {
    return c.json({ error: "Invalid or expired token" }, 401);
  }
};

export { authenticate };
Loading Exercise...

Then, update app.js to use the middleware for the /api/secret route:

// ... other code
import * as middlewares from "./middlewares.js";

// ... other code
app.use("/api/secret", middlewares.authenticate);
app.get("/api/secret", (c) => {
  return c.json({ message: "This is a secret message!" });
});

// ... other code

Now, when we try to access the API without a token, we see that the API returns the 401 status code:

$ curl localhost:8000/api/secret
{"error":"Missing or invalid authorization header"}

To access the protected endpoint, we first need to log in and get a token. To do this, let’s first create a user and then login with that user to get a token:

$ curl -X POST -d '{"email": "user@user.com", "password": "secret"}' localhost:8000/api/auth/register
.. server response
$ curl -X POST -d '{"email": "user@user.com", "password": "secret"}' localhost:8000/api/auth/login
{"message":"Login successful","user":{"email":"user@user.com"},"token":"eyJhbGciOiJIU..."}

Now we can use that token to access the protected endpoint:

$ curl -H "Authorization: Bearer eyJhbGciOiJIU..." localhost:8000/api/secret
{"message":"This is a secret message!"}
Loading Exercise...

Authenticated API requests from the client

Now that we have an API endpoint that requires authentication, let’s add client-side functionality for accessing that endpoint. We already have a login page that stores the JWT token in local storage, so we just need to update our client-side code to include the JWT token when making requests to protected endpoints.

Helper function for authenticated requests

As authenticated requests will be common, it’s best to create a helper function for making authenticated requests. Create a folder utils to src/lib, and create a file called fetchUtils.js into it. Add the following code to the file:

import { browser } from "$app/environment";
import { useAuthState } from "$lib/states/authState.svelte.js";

const authState = useAuthState();

const authFetch = async (url, options = {}) => {
  if (!browser) {
    throw new Error("Authenticated fetch can only be used in the browser");
  }

  const token = authState.token;

  if (!token) {
    throw new Error("No authentication token found");
  }

  const headers = {
    ...options.headers,
    "Authorization": `Bearer ${token}`,
    "Content-Type": "application/json",
  };

  const response = await fetch(url, {
    ...options,
    headers,
  });

  if (response.status === 401) {
    // Invalid or expired token
    authState.logout();
    window.location.href = "/auth/login";
    throw new Error("Invalid or expired token");
  }

  return response;
};

export { authFetch };

The helper function authFetch retrieves the token from the authState store, adds it to the Authorization header, and makes the request. If the response status is 401 (Unauthorized), it logs out the user and redirects to the login page.

Loading Exercise...

Making authenticated requests

Now, we can use the helped function for making authenticated requests. As an example, modify the +page.svelte file in src/routes to fetch the secret message when the user clicks a button:

<script>
  import { authFetch } from "$lib/utils/fetchUtils.js";
  import { PUBLIC_API_URL } from "$env/static/public";

  let message = $state(null);

  const fetchData = async () => {
    try {
      const response = await authFetch(`${PUBLIC_API_URL}/api/secret`);
      const data = await response.json();
      message = data.message;
    } catch (error) {
      message = error.message;
    }
  };
</script>

<button onclick={fetchData}>Fetch Protected Data</button>
<p>Message: {message}</p>

With the layout implemented in the previous part, the page at / now has a button that fetches the protected secret message when clicked. After clicking the button, the view would be similar to Figure 1 below.

Fig 1. After logging in, the user sees a button at the root path of the application. Clicking the button makes an authenticated request to the server, which responds with the message "This is a secret message!", which is shown to the user.

Fig 1. After logging in, the user sees a button at the root path of the application. Clicking the button makes an authenticated request to the server, which responds with the message “This is a secret message!”, which is shown to the user.

On the other hand, if the user is not logged in and they try to click the button, they would see an error message as shown in Figure 2 below.

Fig 2. If the user is not logged in, pressing the button leads to seeing a message "No authentication token found".

Fig 2. If the user is not logged in, pressing the button leads to seeing a message “No authentication token found”.

The error message is thrown by the authFetch function when it doesn’t find a token in the authState store.

Broadly speaking, the authFetch function would be used as a drop-in replacement to the fetch function whenever making requests to protected endpoints. This way, the token is automatically included in the request, and we don’t need to manually add it each time.

Loading Exercise...

Summary

In summary:

  • Authorization must always be implemented on the server side. Client-side checks are only for improving user experience and can be easily bypassed.
  • Authentication middleware validates JWT tokens from the Authorization header and adds user information to the request context, making it available to route handlers.
  • Middleware can be applied to specific routes or groups of routes using Hono’s app.use() method.
  • The authFetch helper function simplifies making authenticated requests from the client by automatically including the JWT token in the Authorization header and handling 401 errors by logging out the user and redirecting to login.
Loading Exercise...