Supabase RBAC and subscription plans in SvelteKit: database roles, JWT claims, and typed locals

Supabase RBAC and subscription plans in SvelteKit: database roles, JWT claims, and typed locals

You define roles and permissions in Postgres, attach user_role and user_plan to each access token with a custom access token hook, then read those claims on the server with jwt-decode and expose them through userWithRole() in hooks.server.ts, app.d.ts, and the root layout so SvelteKit pages get a single, typed snapshot of who the user is—while Postgres Row Level Security and authorize() stay the real gate for data.

Inilathala Na-update 411 mga view

Article

1. What “secure RBAC” means in this stack

  • Identity is still Supabase Auth (auth.users).

  • Authorization has two layers that work together:

    • JWT claims (user_role, user_plan) — fast, available on every request after validation; good for routing and UI.

    • Database rulesrole_permissions + public.authorize() in RLS policies — authoritative; never trust the client alone.

The migration file establishes the data model and the hook that injects claims; SvelteKit then reads those claims from the validated session’s access token.

2. Database: roles, plans, permissions, and the token hook

Your migration (conceptually) does four things:

  1. Enumsapp_permission, app_role, subscription_plan.

  2. Tablesuser_roles (who has which role), user_plans (subscriptions), role_permissions (what each role may do).

  3. custom_access_token_hook — runs when Supabase issues/refreshes tokens; it loads the user’s role and effective plan and writes them into claims as user_role and user_plan.

  4. authorize(requested_permission) — uses auth.jwt() inside Postgres to check if the current JWT’s role has the requested permission (for RLS / SQL policies).

Snippet (structure only):

create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql stable as $$
declare
  claims jsonb;
  user_role public.app_role;
  user_plan public.subscription_plan;
begin
  select role into user_role from public.user_roles
  where user_id = (event->>'user_id')::uuid;

  select coalesce(
    (select max(up.plan) from public.user_plans up where up.user_id = (event->>'user_id')::uuid),
    'free'::public.subscription_plan
  ) into user_plan;

  claims := event->'claims';
  claims := jsonb_set(claims, '{user_role}', coalesce(to_jsonb(user_role), 'null'::jsonb));
  claims := jsonb_set(claims, '{user_plan}', to_jsonb(user_plan));
  return jsonb_set(event, '{claims}', claims);
end;
$$;

You also need this hook registered in the Supabase dashboard (Auth → Hooks → Custom access token) so Supabase actually calls public.custom_access_token_hook. Without that step, the JWT will not contain your custom claims.

authorize() is how Postgres enforces permissions in policies (your app still must not expose admin actions only behind client checks).

3. SvelteKit server: clients, validated session, and decoding the JWT

In hooks.server.ts you:

  1. Create event.locals.supabase (anon key + cookies) for normal server usage.

  2. Create event.locals.supabaseAdmin (service role) where you need elevated server-only operations (use carefully).

  3. Implement safeGetSessionsession from cookies, then getUser() so the JWT is validated before you trust it.

  4. Implement userWithRole — after a valid session exists, jwt-decode parses session.access_token and reads user_role and user_plan from the payload (those keys were added by the hook).

Why jwt-decode (package.json)? The access token is a normal JWT string; decoding is cheap and does not replace verification—you already verified by calling getUser(). Decoding only extracts the custom claims that Supabase put in the signed token.

Relevant shapes from your project:

hooks.server.ts

/** Custom claims from `public.custom_access_token_hook` */
type AppJwtPayload = JwtPayload & {
	user_role?: Enums<'app_role'> | null;
	user_plan?: Enums<'subscription_plan'> | null;
};

event.locals.userWithRole = async () => {
	const { user, session } = await event.locals.safeGetSession();

	if (!user || !session) {
		return { user: null, session: null, user_role: null, user_plan: null };
	}

	try {
		const decoded = jwtDecode<AppJwtPayload>(session.access_token ?? '');
		return {
			user,
			session,
			user_role: decoded.user_role ?? null,
			user_plan: decoded.user_plan ?? null
		};
	} catch (error) {
		console.error(error);
		return { user: null, session: null, user_role: null, user_plan: null };
	}
};

Optional but common: a second Handle (your routeValidationHooks) calls userWithRole() to redirect anonymous users away from protected routes. That is route protection, not RBAC on data—RLS still applies in Supabase.

4. TypeScript: App.Locals and PageData

In app.d.ts you define what locals exposes and what PageData can carry so loaders and +page.svelte files stay typed.

app.d.ts

/** Resolved value from `locals.userWithRole()`; also the shape in `PageData` after load awaits it */
type UserWithRolePayload = {
	user: User | null;
	session: Session | null;
	user_role: Enums<'app_role'> | null;
};

declare global {
	namespace App {
		interface Locals {
			supabase: SupabaseClient<Database>;
			supabaseAdmin: SupabaseClient<Database>;
			safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
			userWithRole: () => Promise<UserWithRolePayload>;
		}
		interface PageData {
			supabase?: SupabaseClient<Database>;
			userWithRole?: UserWithRolePayload;
		}
	}
}

Note for your repo: userWithRole() in hooks.server.ts also returns user_plan, but UserWithRolePayload in app.d.ts currently lists only user_role. For the blog’s “typed end-to-end” story, add user_plan: Enums<'subscription_plan'> | null to UserWithRolePayload so TS matches runtime.

5. Root layout server: resolve once per request

+layout.server.ts awaits userWithRole() and passes cookies for the universal Supabase client on the server branch of layout load:

+layout.server.ts

import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ locals: { userWithRole }, cookies }) => {
	return {
		userWithRole: await userWithRole(),
		cookies: cookies.getAll()
	};
};

Every layout load gets user, session, role, plan (from hooks) without each route reimplementing auth.

6. Root layout universal: browser vs server Supabase + pass-through

+layout.ts builds the right supabase client (browser vs server) and forwards data.userWithRole from the server load:

+layout.ts

export const load: LayoutLoad = async ({ fetch, data, depends }) => {
	depends('supabase:auth');

	const supabase = isBrowser()
		? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {
				global: {
					fetch
				}
			})
		: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {
				global: {
					fetch
				},
				cookies: {
					getAll() {
						return data.cookies;
					}
				}
			});

	return { supabase, userWithRole: data.userWithRole };
};

So: server load resolves identity + RBAC snapshot; layout load exposes supabase everywhere and keeps userWithRole on data for pages and components.

7. End-to-end flow (mental model)

  1. User signs in → Supabase issues tokens.

  2. Custom access token hook (Postgres) adds user_role and user_plan to JWT claims.

  3. SvelteKit hooks.server.ts validates the session with getUser(), then jwt-decode reads those claims from access_token.

  4. +layout.server.ts materializes userWithRole for the request.

  5. +layout.ts merges supabase + userWithRole for all routes.

  6. Supabase RLS / authorize() enforces permissions on actual rows—even if someone tampered with the UI, they cannot elevate privileges without a valid token and matching DB policies.

8. Snippet: “detailed flow” in one place (pseudo-outline)

hooks.server.ts attached to locals

// 1) hooks.server.ts — attach to locals
event.locals.userWithRole = async () => {
  const { user, session } = await event.locals.safeGetSession();
  if (!user || !session) return { user: null, session: null, user_role: null, user_plan: null };

  const decoded = jwtDecode<AppJwtPayload>(session.access_token ?? '');
  return {
    user,
    session,
    user_role: decoded.user_role ?? null,
    user_plan: decoded.user_plan ?? null
  };
};

// 2) +layout.server.ts — run once per navigation (server)
// return { userWithRole: await locals.userWithRole(), cookies: cookies.getAll() };

// 3) +layout.ts — universal load
// return { supabase, userWithRole: data.userWithRole };

// 4) In a +page.svelte or load — use data.userWithRole for UI / branching
// $page.data.userWithRole?.user_role === 'admin'