Multi-tenant workspaces in Express, Hono and Fastify: one middleware that checks membership
Multi-tenant workspaces in Express, Hono and Fastify: take the workspace from the path, then refuse any user who is not a member, in one middleware.
In short
A multi-tenant Express API takes the workspace ID from the route path, then checks in one middleware that the signed-in user is a member, answering 403 if not. Mounted on the router, every handler inherits the check. Hono does the same with createMiddleware, Fastify with a preHandler hook in a prefixed plugin.
The multi-tenant Express tutorials that rank today show you how to read a tenant ID. A
subdomain, an X-Tenant-ID header, a claim in the JWT. Then they hand that ID to
a database query and move on.
Reading the ID was never the hard part. It tells you which workspace the client wants, and nothing about which one it is allowed to have. Skip that second question and your API serves workspace B's invoices to anyone who types B's ID into a request.
Our own setup guides have a version of this gap. The
Express and
Hono pages do
const [workspace] = await bb.workspace.list() and use the first one, and the
Fastify gateway's account() and spend()
stand for one workspace the same way. That is
fine for one workspace per user. Give someone two and "which workspace?" needs
a real answer, which is this page.
The answer fits in one middleware: take the ID from the path, ask BuildBase what this user may do there, refuse if the answer is nothing, and mount it where no handler can be written without it.
Before you start
Sign-in already working through the setup guide for your framework, so every
request carries a BuildBase session. An organization in Platform mode,
which is sold on a plan: new organizations start in Personal mode, one
workspace per user, and there is nothing to switch between. @buildbase/sdk
0.0.73 or later, which has workspace.permissions().
Express: one middleware that fails closed
Add it next to forRequest from the setup guide:
// middleware/workspace.js
const buildbase = require('../config/buildbase');
/**
* Resolves req.params.workspaceId to what this user may do there, or refuses.
* Fails closed: any error ends the request before a handler runs.
*/
exports.requireWorkspace = async (req, res, next) => {
const { workspaceId } = req.params;
if (!workspaceId) return res.status(400).json({ message: 'No workspace.' });
try {
const access = await buildbase
.forRequest(req)
.workspace.permissions(workspaceId);
req.workspace = { id: workspaceId, ...access };
return next();
} catch (err) {
if (err.status === 400 || err.status === 403 || err.status === 404) {
return res.status(403).json({ message: 'No access to this workspace.' });
}
return next(err);
}
};permissions() returns { role, isOwner, permissions }, resolved by
BuildBase's server for the signed-in user. A member gets their role (admin,
editor, viewer, or one your organization defined) and the permission list that
role carries. Not a member? The SDK throws an error with status: 403, and it
throws the same for a workspace that does not exist. A string that is not a
valid workspace ID at all gets 400.
We turn all three into one 403. Why not 404 for the one that does not exist? Because then the status leaks which IDs are real. GitHub makes the opposite call and answers 404 for private repositories it will not show you. Either is defensible. Mixing them is not.
Never 401, though. A 401 says "you are not signed in", and a well-behaved
client acts on it by signing the user out. Our own React SDK does exactly that:
a 401 from BuildBase clears the stored session and calls onSessionExpired.
BuildBase's API once answered membership failures with 401, and the guard's
comment now reads "A 401 here signed people out for opening a workspace they
had lost access to." Your API's clients will draw the same conclusion from
yours.
Anything else, a timeout or a 5xx, goes to next(err) and on to your error
handler. The
request still does not reach a handler. That is what fails closed means here:
there is no path through this function that calls next() without a
membership answer in hand.
Mount it where a handler cannot miss it
// routes/workspace.js
const express = require('express');
const { requireWorkspace } = require('../middleware/workspace');
const reports = require('../controllers/reports');
const router = express.Router({ mergeParams: true });
router.use(requireWorkspace);
router.get('/reports', reports.list);
router.post('/reports', reports.create);
module.exports = router;// app.js
app.use(
'/workspaces/:workspaceId',
isAuthenticated,
require('./routes/workspace')
);mergeParams: true is the line people lose an hour to. Its default is
false, and Express documents what it does as "preserve the req.params
values from the parent router." Without it, :workspaceId belongs to the
app.use path, the router never sees it, req.params.workspaceId is
undefined, and every request gets "No workspace."
Why router.use and not router.param('workspaceId', ...)? Because param
callbacks "are not inherited by mounted apps or routers, nor are they
triggered for route parameters inherited from parent routers." On a
mergeParams router, workspaceId is exactly such an inherited parameter, so
the callback would never fire at all. router.use has no
such exception.
Now a handler reads the workspace it has already been cleared for:
// controllers/reports.js
exports.create = async (req, res) => {
if (!req.workspace.permissions.includes('app:reports:create')) {
return res
.status(403)
.json({ message: 'Your role cannot create reports.' });
}
// ...create the report in req.workspace.id
res.status(201).json({ ok: true });
};No second round trip. bb.workspace.can(workspaceId, 'app:reports:create')
exists and returns a boolean, but it is another request for an answer the
middleware already fetched.
To check it: call GET /workspaces/<your workspace>/reports signed in, then
the same path with a workspace you are not in. The first reaches the handler.
The second answers 403 and the controller never logs a thing.
Key takeaway
Put the membership check in router.use on a router created with
mergeParams: true, and no handler under it can run for a workspace the user
is not in. Refuse with 403, never 401, and hand every other error to
next(err) so the request fails closed.
Hono: one more method on the client, then createMiddleware
Hono's setup guide keeps forSession private inside buildbaseFor. Add one
method to the object it returns, so the middleware does not reach around it:
// src/buildbase.ts (add to the object buildbaseFor returns)
workspaceAccess: (sessionId: string, workspaceId: string) =>
forSession(sessionId).workspace.permissions(workspaceId),// src/workspace.ts
import { getSignedCookie } from 'hono/cookie';
import { createMiddleware } from 'hono/factory';
import { buildbaseFor } from './buildbase';
import { config } from './config'; // the setup guide's env() helper, moved to its own file
type WorkspaceAccess = {
id: string;
role: string | null;
isOwner: boolean;
permissions: string[];
};
export const requireWorkspace = createMiddleware<{
Variables: { workspace: WorkspaceAccess };
}>(async (c, next) => {
const sessionId = await getSignedCookie(
c,
config(c).COOKIE_SECRET!,
'bb_session'
);
if (!sessionId) return c.json({ message: 'Sign in first.' }, 401);
const workspaceId = c.req.param('workspaceId');
if (!workspaceId) return c.json({ message: 'No workspace.' }, 400);
try {
const access = await buildbaseFor(config(c)).workspaceAccess(
sessionId,
workspaceId
);
c.set('workspace', { id: workspaceId, ...access });
} catch (err) {
const status = (err as { status?: number }).status;
if (status === 400 || status === 403 || status === 404) {
return c.json({ message: 'No access to this workspace.' }, 403);
}
throw err;
}
await next();
});// src/index.ts
const workspace = new Hono()
.use(requireWorkspace)
.get('/reports', (c) => c.json({ workspace: c.var.workspace.id }));
app.route('/workspaces/:workspaceId', workspace);Two differences from Express. The 401 for no session is fine here, because
there really is no session; it is only a membership failure that must never be 401. And the Variables type on createMiddleware is what makes
c.var.workspace typed in the handlers chained after .use(). Call .use()
as a separate statement and the sub-app's type never learns about it.
Fastify: encapsulation does the mounting
A hook added inside a registered plugin covers that plugin's routes and nothing
else. Fastify says "all hooks are encapsulated" except onClose. So register
the workspace routes as one plugin with a prefix, and the hook can neither leak
out to other routes nor be skipped by these.
The gateway interface from the setup guide grows one method, and so does the
fake your tests pass in through opts.buildbase, or every workspace route
answers 500 in tests:
// src/plugins/app/buildbase.ts (add to BuildBaseGateway and createGateway)
workspaceAccess(
sessionId: string,
workspaceId: string
): Promise<{ role: string | null; isOwner: boolean; permissions: string[] }>;// src/routes/workspaces.ts
import { FastifyInstance } from 'fastify';
declare module 'fastify' {
interface FastifyRequest {
workspace: {
id: string;
role: string | null;
isOwner: boolean;
permissions: string[];
} | null;
}
}
export default async function workspaceRoutes(fastify: FastifyInstance) {
fastify.decorateRequest('workspace', null);
fastify.addHook('preHandler', async (request, reply) => {
const { workspaceId } = request.params as { workspaceId: string };
try {
const access = await fastify.buildbase.workspaceAccess(
request.session.buildbaseSessionId ?? '',
workspaceId
);
request.workspace = { id: workspaceId, ...access };
} catch (err) {
const status = (err as { status?: number }).status;
if (status === 400 || status === 403 || status === 404) {
return reply
.code(403)
.send({ message: 'No access to this workspace.' });
}
throw err;
}
});
fastify.get('/reports', async (request) => ({
workspace: request.workspace!.id,
}));
}// src/app.ts
fastify.register(workspaceRoutes, { prefix: '/workspaces/:workspaceId' });Why preHandler and not onRequest? Routing runs before onRequest, so the
params exist in both. But preHandler runs after your session and auth hooks,
so the check sees a session that has already been validated.
Do not wrap workspaceRoutes in fastify-plugin. That is how you share a
decorator with the whole app, and it is the opposite of what you want here:
Fastify's docs say prefix "will not work" on a plugin wrapped with it, and
the hook would escape to every route.
Why the path, and not a header or the token
All three frameworks read the ID from /workspaces/:workspaceId, and that is
a choice. The path lands in every access log you already have, so "who touched
workspace X last Tuesday" is a grep. It can be linked, bookmarked and pasted
into a support ticket. A header shows up in none of those. It works, and
reading req.get('X-Workspace-Id') instead is a one-line change to the
middleware, but you give up the logs for nothing.
Subdomains (acme.yourapp.com) are a browser-app decision, usually about
custom domains, and a different problem from membership.
Avoid the JWT claim. It is decided when the token is issued: remove someone from a workspace at 10:00 and a token issued at 09:55 still names it until it expires. Wherever the ID comes from, the check stays the same. None of these sources is proof.
What it costs
One HTTP round trip per request, to BuildBase's API. The server SDK does not cache, so two requests to the same workspace ask twice.
You can put a short in-memory cache in front of permissions(), keyed on
session and workspace. We left it out, on purpose. The price is exact: a member
you remove keeps access for the length of the TTL. Thirty seconds is fine for a
reporting app and wrong for anything that moves money. Choose it knowing that,
or not at all.
When it goes wrong
| What you see | Why, and the fix |
|---|---|
| Every request answers "No workspace." | The router was created without mergeParams: true, so req.params.workspaceId is undefined inside it. |
| Users get signed out when they open a workspace they left. | Something answers membership failures with 401, and the client treats it as a sign-out. Membership is 403. 401 means the session itself is gone. |
| One nested router skips the check. | It hangs off a router.param callback, which mounted routers do not inherit. Move the check to router.use. |
| A Fastify route outside the prefix is guarded, or one inside is not. | The plugin is wrapped in fastify-plugin, which breaks encapsulation and the prefix with it. |
| The right user, the right workspace, still 403. | The server does not count them as a member. An accepted invitation that never became a membership looks exactly like this: see an invited member who cannot see the workspace. |
The worst failure raises no error at all: every query returns everyone's rows.
The middleware decides who may enter a workspace. It does not filter your
database, so every query still needs where workspace_id = req.workspace.id,
or a database per tenant.
Building on React Router instead? The same problem has a different trap there, where a parent loader's check does not guard its children: multi-tenant workspaces in Remix.
Metering usage per workspace builds on this: the
usage metering middleware reads
the workspace, and who may record against it, from req.workspace.
Install
npm i @buildbase/sdk