The workspace runs three independent gates back-to-back. Each one assumes the others might fail — defense in depth.
apps/workspace/proxy.ts#const isPublicRoute = createRouteMatcher([
"/auth(.*)",
"/api/auth/2fa(.*)",
"/api/webhooks/clerk(.*)",
"/api/health",
]);
const ALLOWED_HOSTS = new Set(["workspace.matters.ai"]);
export default clerkMiddleware(async (auth, req) => {
// Host gate runs first — non-allowlisted hosts get a bare 404 before any auth.
// (localhost / 127.0.0.1 / ::1 are also allowed for local dev.)
const host = req.headers.get("host")?.split(":")[0] ?? req.nextUrl.hostname;
if (!isAllowedHost(host)) return new NextResponse("Not Found", { status: 404 });
if (isPublicRoute(req)) return NextResponse.next();
const authResult = await auth();
if (!authResult.userId) {
if (req.nextUrl.pathname.startsWith("/api")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const loginUrl = new URL("/auth/login", req.url);
loginUrl.searchParams.set("redirect_url", req.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
// Email domain allowlist check — fetches the user via clerkClient().users.getUser()
// and tests the primary (then any) email against ALLOWED_EMAIL_DOMAINS.
const client = await clerkClient();
const user = await client.users.getUser(authResult.userId);
// …emailAllowed = isAllowedEmail(primary) || user.emailAddresses.some(isAllowedEmail)
if (!emailAllowed) {
// 403 (API) or /auth/sign-out?error=domain_restricted redirect (page)
}
return NextResponse.next();
});
Runs on every request at the edge before any page or API handler. Cheap, fast, and the primary security boundary. The host check happens before auth, so anything not served on an allowlisted host 404s without ever touching Clerk.
apps/workspace/app/(workspace)/layout.tsx#const user = await currentUser();
if (!user) redirect("/auth/login");
const allowed = isAllowedEmail(primaryEmail) ||
user.emailAddresses.some((e) => isAllowedEmail(e.emailAddress));
if (!allowed) redirect("/auth/sign-out?error=domain_restricted");
const roles = await getCurrentUserRoles();
const userRole = getHighestRole(roles);
Re-runs the allowlist check via Clerk's currentUser(). If middleware somehow let a wrong
identity slip through (config error, race, whatever), the layout catches it before any module
renders. Also fetches RBAC roles so the sidebar can hide unauthorized modules.
import { checkRole } from "@/lib/auth/rbac";
export async function POST(req: NextRequest) {
if (!(await checkRole("org:content:edit"))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// …mutation
}
Every handler that reads or mutates data calls checkRole(...) or the synchronous
hasPermission(roles, ...). See RBAC for the role × permission matrix.
Only four things bypass the auth gate:
/auth/* — the Clerk sign-in / sign-up / 2FA / sign-out UI itself. Has to be public,
obviously./api/auth/2fa/* — runs during the login step (the /auth/verify-2fa page POSTs to it
before the session is fully trusted), so it bypasses the gate. Its handlers self-enforce
Clerk auth() (401 without a userId), so this exposes nothing to anonymous callers.
Note /api/auth/check-2fa is deliberately not public — it's a post-session gate./api/webhooks/clerk — Svix-signed Clerk webhook (signature-verified inside the handler)./api/health — load-balancer health check (returns 200 OK).(The host gate is separate and stricter: it 404s any non-allowlisted host before this matcher even runs.)
matters.ai ↔ workspace.matters.ai#For shared session state across the public site and the workspace subdomain, the Clerk session
cookie's Domain attribute must be set to .matters.ai in the Clerk dashboard (Production
instance → Session → Cookie configuration).
Without that:
workspace.matters.ai will not be considered logged in at matters.ai.@matters/auth's ALLOWED_EMAIL_DOMAINS is currently ["matters.ai", "mreshank.com"].
Adding a domain is a security review item — it grants every Clerk user at that domain access to internal data. Process:
packages/auth/src/email-allowlist.ts.The allowlist check is intentionally exact-match (no subdomain wildcards) so
*.example.com never accidentally lets in a related-but-untrusted org.
| Symptom | Likely cause | Fix |
|---|---|---|
Loop between / and /auth/login | Clerk cookie not sticking | Check Domain=.matters.ai is set and the user's browser allows third-party cookies. |
| 403 on every API call from a logged-in user | Email not in allowlist | Either add the domain or invite the user with an @matters.ai address. |
| 403 from a manager user on a specific module | Role doesn't include the required permission | Check Clerk → User → Public metadata → role (primary) + additionalRoles (array). Cross-reference with lib/auth/rbac-shared.ts. |
| New Clerk user has no roles / behaves like a viewer | Lazy-provisioning kicked in via /api/user/profile | Expected. It inserts a Mongo user with role: "org:viewer" and additionalRoles: []. With no roles in Clerk public metadata, permission checks resolve to none (getHighestRole([]) defaults to org:viewer, whose permission set is empty). Promote via the Workspace → System → Users UI. |