Multi-Tenant Single-Page Apps: Dos and Don'ts

François Zaninotto
François ZaninottoDecember 14, 2022
#react#react-admin#architecture#js#popular

How can you implement multitenancy in a Single-Page Application (SPA) built with React, Vue, Svelte, or any other frontend framework? This is a question we get asked a lot. In this post, we'll share our experience with multi-tenant apps, and explain how to build them with react-admin - although the same principles apply to any tool used to build an SPA.

What is a Multi-Tenant App?

A multi-tenant web application is a single instance of an application that serves multiple customers. All the customers share the same database, but each customer can only access their own data. The opposite of multi-tenant apps is multi-instance apps, where each customer is served by a separate instance.

Multi-tenant vs multi-instance apps

Most Software-as-a-Service (SaaS) applications, like ZenDesk, MailChimp, or Hubspot, are good examples of multi-tenant apps.

The Problem

For this tutorial, we'll convert a mono-tenant application to a multi-tenant app. The application is a simple helpdesk, where customers can submit tickets and agents can answer them. It's built with React and React Admin, a frontend framework for building B2B apps and admins.

The helpdesk application

In the screen above, the agent sees the tickets from all the customers. The code for fetching the tickets from the API looks like the following:

const { data, isLoading } = useGetList('tickets', {
    pagination: { page: 1, perPage: 25 },
    sort: { field: 'id', order: 'DESC' },
    filter: {},
});

In a multi-tenant app, an agent belongs to one tenant, and must only see the tickets for this tenant. This means the API must be able to identify the tenant and filter the data requests accordingly.

The Solution

On login, the SPA receives a JWT token from the authentication server. The token contains credentials to access the API. The server can also take advantage of that token to send additional data for the frontend to use, like for instance the tenant ID. The SPA should grab this data and store it in the local storage. In a react-admin app, this happens in the authentication provider:

const authProvider = {
    login: async ({ username, password }) => {
        const request = new Request(`${API_URL}/login`, {
            method: 'POST',
            body: JSON.stringify({ username, password }),
            headers: new Headers({ 'Content-Type': 'application/json' }),
        });
        const response = await fetch(request);
        if (response.status < 200 || response.status >= 300) {
            throw new Error(response.statusText);
        }
        const { token } = await response.json();
        localStorage.setItem('token', token);
+       const { tenantId } = jwtDecode(token);
+       localStorage.setItem('tenantId', tenantId);
    },
    // ...
}

This allows the SPA to add the tenant ID to relevant API requests, and filter the data accordingly. For instance, the request to fetch the tickets list would have to be modified like this:

+const tenantId = localStorage.getItem('tenantId');
const { data, isLoading } = useGetList(
    'tickets',
    {
        pagination: { page: 1, perPage: 25 },
        sort: { field: 'id', order: 'DESC' },
-       filter: {},
+       filter: { tenantId },
    },
);

To avoid adding the filter manually on each page that displays a list of filters, it's preferable to make this change in the code responsible for communicating with the API (the "model layer"). In a react-admin application, that means in the data provider - a central object that translates the application queries into API requests.

For the tickets list, this would translate to the following code:

const dataProvider = {
    getList: async (resource, params) => {
        const filter = params.filter || {};
+       if (resource === 'tickets') {
+           filter.tenantId = localStorage.getItem('tenantId');
+       }
        const { page, perPage } = params.pagination;
        const { field, order } = params.sort;
        const query = {
            filter: filter,
            sort: [field, order],
            range: [(page - 1) * perPage, page * perPage - 1],
        };
        const url = `${API_URL}/${resource}?${stringify(query)}`;
        const request = new Request(url, {
            headers: new Headers({
                Accept: 'application/json',
                Authentication: `Bearer ${localStorage.getItem('token')}`,
            }),
        });
        const response = await fetch(request)
        if (response.status < 200 || response.status >= 300) {
            throw new Error(response.statusText);
        }
        const { data, total } = await response.json();
        return { data, total };
    },
    // ...
};

The API will therefore always receive the tenant ID, and filter the data accordingly.

GET /tickets?filter={"tenantId":123}&sort=["id","DESC"]&range=[0,24]
Authentication: Bearer <token>

Problem solved?

Wrong way

Why It's A Bad Idea

In this solution, it's the responsibility of the SPA (frontend) to add the tenant ID. But the JavaScript of the SPA can be altered in the browser, and the API can be called without the SPA, for instance using a client like curl. This creates a serious security issue.

A malicious user could call the API and pass an arbitrary tenant ID in the query string:

GET /tickets?filter={"tenantId":666}&sort=["id","DESC"]&range=[0,24]
Authentication: Bearer <token>

This means the API MUST make sure that the tenant ID from the filter corresponds to the tenant of the authenticated user.

But then, if the API has to check the tenant ID, it means the developer must do the work twice:

  • on the client side, add the tenant filter to every API request
  • on the server side, and check if the tenant filter matches the user tenant.

Instead, why not let the API do the job, based on the user credentials? This would avoid the need to add the tenant ID to every request, and would make the API more secure.

So the API would receive a request like this:

GET /tickets?filter={}&sort=["id","DESC"]&range=[0,24]
Authentication: Bearer <token>

Then, based on the user credentials, the API would get the tenant ID, add it as a filter, and return only the tickets for the current user's tenant.

This means the frontend application code needs no modification whatsoever to support multitenancy. The API is responsible for adding the tenant ID as a filter, and the frontend code can be left untouched. This has the advantage of never revealing the tenant ID to the frontend code - or even showing that the application is actually multi-tenant. That's a good security practice.

The Good Solution

In practice, how can an API add a filter based on the user credentials? To illustrate this, let's build a proxy server, that will sit between the frontend and the API. The proxy will receive the requests from the frontend, and add the tenant ID to the filter based on the user credentials. Then, it will forward the request to the API, and return the response to the frontend.

In Node.js with the express framework, this would look like this:

const express = require('express');
const fetch = require('node-fetch');
const jwt = require('jsonwebtoken');

const app = express();

const APP_SECRET = '...';
const API_URL = 'https://example.com/api';
const API_TOKEN = '...';

app.get('/tickets', async (req, res) => {
    // Read the JWT token from the Authentication header
    const authHeader = req.get('Authentication');
    const token = authHeader.replace('Bearer ', '');

    try {
        // Validate and decode the JWT token using the app secret
        const decodedToken = jwt.verify(token, APP_SECRET);
        // The token is valid, so we can proxy the request

        // Get the tenant Id from the JWT token payload
        const { tenantId } = decodedToken;
        // Add the tenant id to the filter
        const filter = JSON.parse(req.query.filter || '{}');
        filter.tenantId = tenantId;
        // Make a GET request to the target URL with the filter parameter
        const url = `${API_URL}/tickets?${stringify({
            ...req.query,
            filter: JSON.stringify(filter),
        })}`;
        const response = await fetch(url, {
            headers: new Headers({
                Accept: 'application/json',
                Authentication: `Bearer ${API_TOKEN}`,
            }),
        });
        // Send the response from the target URL back to the client
        res.send(response);
    } catch (err) {
        // The JWT token is invalid, so return an error
        res.status(401).send({ error: 'Invalid token' });
    }
});

app.listen(3000);

There are many ways to do this, either in a central middleware or on a per-resource basis. The complexity comes from the fact that the tenantId from the JWT token must be used in a different way for each CRUD call:

  • for getOneById requests (e.g. GET /tickets/:id), the tenantId must be used after fetching the record, to check if the user has access to it
  • for getList requests (e.g. GET /tickets), the tenantId must be used before fetching the records, to add a filter
  • for create requests (e.g. POST /requests), the tenantId must be used before creating the record, to add it to the record
  • for update requests (e.g. PUT /requests/:id), the tenantId must be used before updating the record, to check if the user has access to it
  • for delete requests (e.g. DELETE /tickets/id), the tenantId must be used before deleting the record, to check if the user has access to it

What If I Can't Modify The Server Code?

You could get around adding specific filtering logic on the server by asserting that any tenantId passed by the SPA in a query parameter matches the tenantId of the authenticated user. But that wouldn't prevent a malicious user from passing no tenantId at all and getting the data for all tenants. And it wouldn't work for the CRUD routes that don't use the filter parameter, like getOneById, update, etc.

There is no way around it: if you can't modify the server code, you can't implement multitenancy in a secure way. You may find tutorials on the web that show how to implement multitenancy without modifying the server code, but they are all dangerously flawed. Don't try this at home!

The is only one way to do it

Conclusion

Multitenancy is about adding fences between customers' data. You can't do it securely without modifying the server code. And you don't need to modify the frontend code to do it.

So, to the question: What modification should I make to my frontend code to support multitenancy? The answer is none. Multitenancy is a server-side concern only.

Did you like this article? Share it!