Users and Security

Role-Based Access Control


Learning Objectives

  • You understand Role-Based Access Control (RBAC).
  • You know how to add roles to users in a database.
  • You can include user roles in JWT tokens and extract them on the server.
  • You know how to protect API endpoints based on user roles.
  • You can conditionally show UI elements based on user roles.

Sometimes, applications require different levels of access for different users. For example, an application might have an administrator who can view and manage all users, while a regular user can only manage their own content. This is where Role-Based Access Control (RBAC) comes in.

Role-Based Access Control (RBAC) is a method of restricting access to certain parts of an application based on the role of the user. In RBAC, users are assigned roles, and these roles determine what actions the user can perform and what parts of the application they can access.

Common examples of roles include:

  • Admin: Can manage all content and users
  • Moderator: Can manage content but not users
  • User: Can only manage their own content
  • Guest: Can only view public content

In web applications, RBAC is typically implemented by (1) storing user roles in the database, (2) including roles in the JWT token when users log in, (3) checking roles on the server before allowing access to protected resources, and (4) using roles on the client to show or hide UI elements.

RBAC can be more complex than what we’ll cover here. For example, RBAC systems can include hierarchical roles where higher-level roles inherit permissions from lower-level roles, or fine-grained permissions where roles are associated with specific actions rather than broad categories. We’ll keep it simple for this introduction.

Loading Exercise...

Adding roles to the database

To implement RBAC, we first need a way to store user roles in the database. Create a new database migration file called V7__user_roles.sql (adjust the version number to match your migration sequence) in the database-migrations folder:

CREATE TABLE user_roles (
  id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id),
  role TEXT NOT NULL,
  UNIQUE(user_id, role)
);

The above adds a table user_roles to the database, which has a foreign key to the users table and a role column. The unique constraint UNIQUE(user_id, role) ensures that a user cannot have the same role twice in the database. Run the migration to create the table.

We could also have a separate table for roles, but for simplicity, we’ll just use text values for roles in this example.

Now, we can add roles to users. As an example, if the id of the user is 1, we can add the ADMIN role to that user with the following SQL command:

INSERT INTO user_roles (user_id, role) VALUES (1, 'ADMIN');
Loading Exercise...

Including roles in JWT tokens

To make roles available throughout the application, we include them in the JWT token when users log in. This way, the server can check the user’s roles without querying the database on every request.

Modify the authRepository.js to include a function to fetch user roles:

// ...

const getUserRoles = async (userId) => {
  const result = await sql`
    SELECT role FROM user_roles WHERE user_id = ${userId}
  `;

  return result.map((row) => row.role);
};

// remember to export the function

The above function reads all roles for a given user from the database and returns them as an array of strings.

Then, update the login function in authController.js to fetch and include roles in the token:

// ...

const login = async (c) => {
  const user = await c.req.json();

  const foundUser = await authRepository.findByEmail(user.email);
  if (!foundUser) {
    return c.json({ error: "Invalid email or password" }, 401);
  }

  const isValid = verify(user.password, foundUser.password_hash);
  if (!isValid) {
    return c.json({ error: "Invalid email or password" }, 401);
  }

  const roles = await authRepository.getUserRoles(foundUser.id);
  const payload = { email: foundUser.email, id: foundUser.id, roles };
  const token = await jwt.sign(payload, JWT_SECRET);

  return c.json({
    message: "Login successful",
    user: payload,
    token,
  });
};

Now, when users log in, their JWT token contains their roles. The roles are also included in the user object returned to the client.

Loading Exercise...

Showing roles in the user interface

Now as the roles are included in the user object returned to the client, we can show them in the user interface. Update the layout src/routes/+layout.svelte to show the user roles, if they have any:

<script>
  import { useAuthState } from "$lib/states/authState.svelte.js";
  let { children } = $props();
  const authState = useAuthState();
</script>

<header>
  {#if authState.user}
    <div>
      <span>
        Hello, {authState.user.email}!
        {#if authState.user.roles?.length}
          (Roles: {authState.user.roles.join(", ")})
        {/if}
      </span>
      <button onclick={() => authState.logout()}>Logout</button>
    </div>
  {:else}
    <ul>
      <li><a href="/auth/login">Login</a></li>
      <li><a href="/auth/register">Register</a></li>
    </ul>
  {/if}
</header>

<main>
  {@render children()}
</main>

Now, if the user has roles, they are displayed next to their email, as shown in Figure 1 below.

Fig 1. If a user has roles, the roles are shown next to their email after logging in.

Fig 1. If a user has roles, the roles are shown next to their email after logging in.

Protecting API endpoints with roles

Now that roles are included in the JWT token, we can use them to protect API endpoints. Let’s create an admin-only endpoint that allow administrators to view all users in the system.

Creating role-checking middleware

First, let’s create a reusable middleware function that checks if a user has a required role. Add this to the middlewares.js:

const requireAnyRole = (...requiredRoles) => {
  return async (c, next) => {
    const user = c.get("user");

    if (!user) {
      return c.json({ error: "Authentication required" }, 401);
    }

    if (
      !user.roles || !user.roles.some((role) => requiredRoles.includes(role))
    ) {
      return c.json({ error: "Insufficient permissions" }, 403);
    }

    await next();
  };
};

// remember to export the function

The middleware is a factory function — a function that returns a function — that allows creating customized middleware for different role requirements. For example, the call requireAnyRole("ADMIN", "MODERATOR") would create a middleware function that verifies that the user has either the ADMIN or MODERATOR role. If the user does not have any of the required roles, the middleware responds with a 403 Forbidden status code. If the user is not authenticated, it responds with a 401 Unauthorized status code.

Loading Exercise...

Admin endpoint for viewing all users

Then, let’s create the functionality for viewing users in the system. First, create a file called userRepository.js and add the following function to fetch all users along with their roles:

import postgres from "postgres";

const sql = postgres();

const getAllUsers = async () => {
  return await sql`
    SELECT
      users.id,
      users.email,
      users.created_at,
      array_agg(user_roles.role) FILTER (WHERE user_roles.role IS NOT NULL) as roles
    FROM users
    LEFT JOIN user_roles ON users.id = user_roles.user_id
    GROUP BY users.id, users.email, users.created_at
    ORDER BY users.created_at DESC
  `;
};

export { getAllUsers };

This query fetches all users and aggregates their roles into an array. Users without roles will have an empty array.

The array_agg is a PostgreSQL-specific function.

Then create a controller for users called userController.js and place the following content to the file.

import * as userRepository from "./userRepository.js";

const getAllUsers = async (c) => {
  const users = await userRepository.getAllUsers();
  return c.json(users);
};

export { getAllUsers };

Finally, wire up the endpoint in app.js with role protection:

// ...

import * as userController from "./userController.js";

// Admin-only routes
app.use("/api/admin/*", middlewares.authenticate, middlewares.requireAnyRole("ADMIN"));
app.get("/api/admin/users", userController.getAllUsers);

export default app;

Notice above how we chain the authenticate and requireAnyRole("ADMIN") middleware to protect all routes starting with /api/admin. Only authenticated users with the ADMIN role can access these routes.

We could also implement the role functionality to the authenticate middleware, but separating authentication and role checking into different middleware functions is cleaner.

Time to test the API endpoint

Now, it would again be the time to test the API endpoint using e.g. curl. First, try accessing the endpoint without a token, then with a token of a user without the ADMIN role, and finally with a token of a user with the ADMIN role. You should see 401 Unauthorized, 403 Forbidden, and 200 OK responses, respectively.


Loading Exercise...

Using roles on the client

Now that we have role-based endpoints on the server, let’s update the client to conditionally show UI elements based on user roles.

Let’s update the layout to show an admin link for users with the admin role. Update src/routes/+layout.svelte:

<script>
  import { useAuthState } from "$lib/states/authState.svelte.js";
  let { children } = $props();
  const authState = useAuthState();
</script>

<header>
  {#if authState.user}
    <div>
      <span>
        Hello, {authState.user.email}!
        {#if authState.user.roles?.length}
          (Roles: {authState.user.roles.join(", ")})
        {/if}
      </span>
      {#if authState.user.roles?.includes("ADMIN")}
        <ul>
          <li><a href="/admin">Admin</a></li>
        </ul>
      {/if}
      <button onclick={() => authState.logout()}>Logout</button>
    </div>
  {:else}
    <ul>
      <li><a href="/auth/login">Login</a></li>
      <li><a href="/auth/register">Register</a></li>
    </ul>
  {/if}
</header>

<main>
  {@render children()}
</main>

The link to the admin pages only shown to users who have the “ADMIN” role. The ?. optional chaining ensures we don’t get an error if roles is undefined. Let’s next create the admin page.

Loading Exercise...

Creating an admin page

Now let’s create an admin page that displays all users. Create src/routes/admin/+page.svelte and place the following content to it. For simplicity, as the focus is on demonstrating role-based access control, we go with a naive page that just lists the users and their roles. In a real application, you would likely want to add more functionality, such as editing user roles or deleting users.

<script>
  import { useAuthState } from "$lib/states/authState.svelte.js";
  import { authFetch } from "$lib/utils/fetchUtils.js";
  import { PUBLIC_API_URL } from "$env/static/public";
  import { goto } from "$app/navigation";

  const authState = useAuthState();
  let users = $state([]);

  const loadUsers = async () => {
    const response = await authFetch(`${PUBLIC_API_URL}/api/admin/users`);
    users = await response.json();
  };

  $effect(() => {
    // this could go to a layout file, with a redirect to home if not admin
    if (!authState.user || !authState.user.roles?.includes("ADMIN")) {
      return;
    }

    loadUsers();
  });
</script>

<h1>Users</h1>

<ul>
{#each users as user}
  <li>{user.email} - Roles: {user.roles?.join(", ")}</li>
{/each}
</ul>

Now, the application shows an admin link in the header for users with the “ADMIN” role. Clicking the link takes the user to the admin page, which fetches and displays all users from the server. In Figure 2 below, the system has two users, one with the ADMIN role and one without.

Fig 2. An admin user visits the page at path /admin. There are a total of two users in the system, one with the admin role and one without.

Fig 2. An admin user visits the page at path /admin. There are a total of two users in the system, one with the admin role and one without.
Loading Exercise...

Guarding the admin page

To check that only users with the “ADMIN” role can view the admin page, we can add +page.js file to the src/routes/admin folder with the following content:

import { browser } from "$app/environment";
import { error } from "@sveltejs/kit";

export const load = () => {
  if (browser) {
    const user = localStorage.getItem("user");
    if (!user) {
      throw error(401, "Unauthorized");
    }

    const roles = JSON.parse(user).roles || [];
    if (!roles.includes("ADMIN")) {
      throw error(403, "Forbidden");
    }
  }
};

Now, when a user navigates to the page, the load function checks if the user has the “ADMIN” role. If not, it throws an error, which SvelteKit handles by showing an error page.

Note that this is a basic client-side check and that any knowledgeable user could add a role to their local storage and view the page. The real protection is on the server side with the role-checking middleware we added earlier. The client-side check is mainly for user experience, to avoid showing a page that the user should not use.

Now, when an unauthorized user tries to access the admin page, they see an error message like in Figure 3 below.

Fig 3. A user who is not logged in visits the page at path /admin. The page shows a 401 Unauthorized error.

Fig 3. A user who is not logged in visits the page at path /admin. The page shows a 401 Unauthorized error.

Loading Exercise...

Summary

In summary:

  • Role-Based Access Control (RBAC) restricts access to parts of an application based on user roles (e.g., Admin, Moderator, User).
  • User roles are stored in a separate database table with a foreign key to the users table, allowing users to have multiple roles.
  • Roles should be included in the JWT payload during login, making them available throughout the application without additional database queries.
  • Role-checking middleware can be implemented as a factory function that returns middleware checking for specific roles, enabling reusable and flexible access control.
  • API endpoints can be protected by chaining authentication and role-checking middleware, returning 401 for unauthenticated requests and 403 for insufficient permissions.
  • User interfaces should conditionally show elements based on roles (e.g., admin links only for admins), improving user experience.
  • Client-side route protection using the load function provides an additional layer of user experience, but server-side role checking is essential for actual security.
Loading Exercise...