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=yesstyle flags travel inreq.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