Skip to main content

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

LayerConcernImplementation
Edge middlewareSession cookies + auth redirect + permissions cache invalidation on hard refreshsrc/middleware.tssrc/lib/supabase/updateSession.ts
Route-group server layoutsTeam / campaign access checks(team-layout)/layout.tsx, (campaign-layout)/.../layout.tsx
Page guardsPer-permission-key gatingrequireAccess({ teamId, campaignId, key })
Sidebar / conditional UIHide controls a user cannot usepermissionKeys 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:

  1. Refreshes Supabase cookies via createServerClient(...) + getClaims().
    • getClaims() verifies the JWT locally against the project's JWKS (cached in-memory). No /auth/v1/user round-trip per request.
  2. Honors a stored redirect_url cookie. After sign-in we send the user back to where they were trying to go.
  3. Forces sign-in for non-/auth/* paths if no valid session exists. The intended path is stored in the redirect_url HTTP-only cookie (lax SameSite, 10-minute TTL).
  4. On a real browser refresh (detected via sec-fetch-dest=document + cache-control: no-cache|max-age=0), POSTs to /api/permissions/revalidate so 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_cache is 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 teamId is set and the user has no team access → redirect("/no-access").
  • If campaignId is set and the user has no campaign access (and is not in the super-admin team) → redirect("/$\{teamId\}/campaign/no-access").
  • If key is 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 on no-access pages (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-snapshot Supabase 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.