Guides
Build guide

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.

Dharmendra Jagodana7 min read

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 seeWhy, 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
express
hono
fastify
workspaces

Frequently Asked Questions

Should the tenant ID come from a header, the URL path or a subdomain?

For an API, the path: it shows up in access logs and a URL can be linked and bookmarked. A header works too. Subdomains suit browser apps with a custom domain per customer. Wherever it comes from, treat it as a request, never as proof of access.

Why not put the workspace ID in the JWT?

A claim is fixed when the token is issued. A user who switches workspace, or is removed from one, keeps the old claim until the token expires, so the token says yes after the answer has changed.

Should the API return 403 or 404 for a workspace the user is not in?

Pick one answer for "not a member" and "does not exist", so the status never confirms a workspace exists. GitHub uses 404 for private repositories. BuildBase answers 403 for both. Either way, never 401: 401 means the session is gone, and clients sign the user out on it.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime