Middleware & Route Protection
This page documents the active middleware + route-protection architecture. The legacy routePipelines middleware is no longer wired in — see Middleware Directory for context.
TL;DR
| Layer | Concern | Implementation |
|---|---|---|
| Edge middleware | Session cookies + auth redirect + permissions cache invalidation on hard refresh | src/middleware.ts → src/lib/supabase/updateSession.ts |
| Route-group server layouts | Team / campaign access checks | (team-layout)/layout.tsx, (campaign-layout)/.../layout.tsx |
| Page guards | Per-permission-key gating | requireAccess({ teamId, campaignId, key }) |
| Sidebar / conditional UI | Hide controls a user cannot use | permissionKeys from TeamsContext |
1. Edge middleware (src/middleware.ts)
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/updateSession";
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
The middleware runs on every non-static request. All real logic lives in updateSession.
2. updateSession (src/lib/supabase/updateSession.ts)
updateSession does four things:
- Refreshes Supabase cookies via
createServerClient(...)+getClaims().getClaims()verifies the JWT locally against the project's JWKS (cached in-memory). No/auth/v1/userround-trip per request.
- Honors a stored
redirect_urlcookie. After sign-in we send the user back to where they were trying to go. - Forces sign-in for non-
/auth/*paths if no valid session exists. The intended path is stored in theredirect_urlHTTP-only cookie (lax SameSite, 10-minute TTL). - On a real browser refresh (detected via
sec-fetch-dest=document+cache-control: no-cache|max-age=0), POSTs to/api/permissions/revalidateso the cached permissions snapshot for the user is dropped before the next render. Without this step, refresh would still serve the stale entry that subsequent<Link>navigations also hit.
Edge middleware deliberately does not fetch permissions itself —
unstable_cacheis unavailable in Edge ("incrementalCache missing"), and running an uncached snapshot fetch on every navigation is expensive.
3. Layout-based access checks
Team layout
src/app/[primaryTeam]/(team-layout)/layout.tsx reads params.primaryTeam and calls:
const permissions = await getRoutePermissions({ teamId: params.primaryTeam });
if (!permissions.teamAccess) redirect("/");
It then hydrates TeamsContext with teams, campaigns, permissionKeys, and superAdminTeamId.
Campaign layout
src/app/[primaryTeam]/(campaign-layout)/campaign/[slug]/layout.tsx calls:
const permissions = await getRoutePermissions({
teamId: params.primaryTeam,
campaignId: params.slug,
});
if (!permissions.teamAccess) redirect("/");
if (
!permissions.campaignAccess &&
params.primaryTeam !== SUPER_ADMIN_TEAM_ID
) {
redirect(`/${params.primaryTeam}/campaign/no-access`);
}
The super-admin team always passes campaign checks.
4. Page-level guard (requireAccess)
For per-route permission keys, individual pages call requireAccess:
import { requireAccess } from "@/services/accessCheck/requireAccess";
export default async function Page({ params }) {
await requireAccess({
teamId: params.primaryTeam,
campaignId: params.slug,
key: ["campaign-petitions-page", "campaign-petitions-create"],
});
// ...render
}
Implementation (src/services/accessCheck/requireAccess.ts):
- Loads the snapshot via
getRoutePermissions. - If
teamIdis set and the user has no team access →redirect("/no-access"). - If
campaignIdis set and the user has no campaign access (and is not in the super-admin team) →redirect("/$\{teamId\}/campaign/no-access"). - If
keyis provided, access is granted when the user holds any one of the keys (OR semantics). Fails redirect to/no-access(team scope) or/$\{teamId\}/campaign/no-access(campaign scope). Super-admin team is exempt.
5. Client-side conditional UI
Sidebar bundles in src/components/routes/** and a handful of feature components read permissionKeys from TeamsContext:
const { permissionKeys, superAdminTeamId } = useTeams();
const showSignatures =
permissionKeys?.includes("campaign-signatures-page") ||
params.primaryTeam === superAdminTeamId;
This is purely a UX layer. The route guard above is the actual security boundary.
6. Cache invalidation hooks
Anything that changes a user's effective permissions should call revalidateTag(\permissions-${userId}`)`:
app/api/permissions/revalidate/route.ts— POST endpoint used by middleware on browser refresh and by the "Refresh permissions" button onno-accesspages (src/components/common/refresh-permissions-button.tsx).- Server actions that change role/permission-set assignments should fire a similar revalidation after the mutation.
See Permissions Snapshot for the full caching model.
7. What the legacy routePipelines did
The old middleware-based pipeline (src/middleware/routePipelines.ts) ran handlers like checkPrimaryTeamAllowed, checkCampaignHome, checkGenFeat, etc. on every request. Those files are kept only for historical reference; nothing imports routePipelines today.
Key reasons for the move:
- Edge middleware can't use
unstable_cache, so each request would refetch permissions. - Permission state is now resolved by the
get-permissions-snapshotSupabase Edge Function in a single query, not by reading bespoke session cookies. - Layout-level guards play nicely with React's streaming render and let us colocate loading UI.