Multi-Tenant Single-Page Apps: Dos and Don'ts
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.
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.
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?
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
), thetenantId
must be used after fetching the record, to check if the user has access to it - for
getList
requests (e.g.GET /tickets
), thetenantId
must be used before fetching the records, to add a filter - for
create
requests (e.g.POST /requests
), thetenantId
must be used before creating the record, to add it to the record - for
update
requests (e.g.PUT /requests/:id
), thetenantId
must be used before updating the record, to check if the user has access to it - for
delete
requests (e.g.DELETE /tickets/id
), thetenantId
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!
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.