Resolve the tenant from the host, then rely on the claim, not the URL

Map hostname to tenant in middleware, rewrite to /[tenant]/path with cookies and query intact, and treat the check as UX; RLS on the claim is the boundary.

RLS & Auth

· Chapter

25

·

3

min read

The answer. Customer domains (tickets.acme.com) are a routing concern, not an authorization one. The middleware reads the host header, maps it to a tenant id, and rewrites the request internally to /[tenant]/… so the rest of the app keeps one path-based shape. Two ways to map: a static table generated at build time from the tenants table (fastest; rebuild it from a webhook when a tenant is added) or a per-request lookup with the admin client (instant onboarding; one serial database round trip on every request, so cache it). Whatever the middleware decides is a convenience: a wrong tenant in the URL must produce an empty page, not a leak, and that is the job of the policies comparing the JWT's tenant claim with each row. Sign a user out server-side when their claim does not include the tenant they landed on, before the browser stores the session.

The pattern.

export async function middleware(req: NextRequest) {
  const host = req.headers.get("host")?.split(":")[0] ?? "";
  const tenant = process.env.OVERRIDE_TENANT_DOMAIN ? TENANT_MAP[process.env.OVERRIDE_TENANT_DOMAIN] : TENANT_MAP[host];
  if (!tenant) return NextResponse.rewrite(new URL("/not-found", req.url));
  const { response, claims } = await refreshSession(req);                        // see the SSR entry
  if (claims && !claims.app_metadata.tenants?.includes(tenant) && req.nextUrl.pathname.startsWith("/app"))
    return NextResponse.rewrite(new URL("/not-found", req.url));
  const rewritten = NextResponse.rewrite(new URL(`/${tenant}${req.nextUrl.pathname}${req.nextUrl.search}`, req.url), { request: req });
  response.cookies.getAll().forEach((c) => rewritten.cookies.set(c));        // a rewrite drops refreshed cookies
  return rewritten;
}

Watch out.

  • Framework config rewrites run after middleware, redirects before it; do the tenant rewrite in middleware itself.
  • Build absolute URLs from protocol, host header and port; new URL("/", req.url) can carry the wrong hostname behind a rewrite.
  • Cache-busting query strings and ?magicLink=yes style flags travel in req.nextUrl.search; forget it and flows break only on custom domains.

Related: multi-tenancy-tenant-id-on-every-row-and-in-the-jwt · ssr-one-server-client-per-request-cookies-refresh · server-side-trust-getclaims-not-getsession