Empower your membership platform with robust, database-level authorization. When you centralize access rules in Supabase, you can confidently scale your content, knowing every member experience stays secure and aligned with your membership tiers from day one.
Most membership sites gate content in the application layer. A page checks a session, decides the visitor is a Pro member, and renders the lesson. That works right up until a second entry point exists, and there is always a second entry point eventually.
Supabase offers a different arrangement, because the authorization rule can live in the database itself. A query for content the member hasn't paid for returns nothing, whether it comes from your page, a stray API route, or a curious person with the project URL.
This guide builds a tiered membership on that foundation, on Webflow Cloud, with the gate written in SQL rather than in a component.
What do you need to build a Supabase membership on Webflow Cloud?
You need a Supabase project with working authentication, a Webflow Cloud app to serve the gated pages, and a willingness to write access rules in SQL rather than as component conditions.
Here is the full list before the first migration:
- A Supabase project, with email or social sign-in already configured
- Your project URL and publishable key, plus the secret key stored separately for admin-only work
- A Webflow Cloud project running Next.js 15 or higher, with Node.js 22 or later locally
- The
@supabase/ssrpackage, which handles the session cookies a server-rendered app needs
If sign-in isn't working yet, our Supabase Auth guide covers that layer, and this one starts where it ends. Once authentication works, everything below hinges on a single decision: the tier lives in a table the member cannot write to.
5 steps to gate content by membership tier in Webflow
The build is two tables, a policy that joins them, a route that queries with the publishable key, a webhook that changes tiers when somebody pays, and an expiry checked at read time.
The order is deliberate, because each step assumes the guarantees established by the one before it.
1. Model the tier as data the member cannot change
Create a memberships table keyed on the Supabase user ID, with the tier and an expiry. Keep it separate from any profile table the member can edit, because the moment tier and display name live on the same writable row, a profile update becomes a free upgrade.
Then create the content table with the tier each item requires:
-- Membership tier lives on a row the member cannot edit.
create table public.memberships (
user_id uuid primary key references auth.users on delete cascade,
tier text not null default 'free' check (tier in ('free', 'pro', 'vip')),
expires_at timestamptz
);
create table public.lessons (
id uuid primary key default gen_random_uuid(),
title text not null,
body text not null,
required_tier text not null default 'free'
check (required_tier in ('free', 'pro', 'vip'))
);
-- Without this, any role holding a grant can read the whole table.
alter table public.memberships enable row level security;
alter table public.lessons enable row level security;
-- Adding policies does not remove existing grants, so revoke first.
revoke all on table public.memberships from anon, authenticated;
revoke all on table public.lessons from anon, authenticated;
grant select on table public.memberships to authenticated;
grant select on table public.lessons to authenticated;
-- Every signup needs a row, or the policy below finds nothing and
-- even free lessons stay hidden.
create function public.seed_membership()
returns trigger language plpgsql security definer as $$
begin
insert into public.memberships (user_id)
values (new.id)
on conflict do nothing;
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.seed_membership();
The trigger is not decoration. The lessons policy asks whether a membership row exists, so a member who signs up and never pays needs one seeded at the free tier or the policy finds nothing and hides even the free lessons. Without it, the default 'free' on the table never applies to anybody.
Two other lines matter more than they look. Enabling Row Level Security is what makes the table private: Supabase is explicit that a table in an exposed schema without RLS is readable and writable by any role holding a grant on it.
The revoke is the subtle one. Adding policies does not remove existing grants, so on a project that still grants anon and authenticated by default, you revoke first and grant back only what each role actually needs. You finish this step with two tables that return nothing to anybody.
2. Write the policy that is the actual gate
A policy behaves like a WHERE clause appended to every query against the table, and it runs inside Postgres rather than in your app. That is the whole point: it applies no matter which route, script or client asks.
The membership policy is the simple one, and the content policy is where the tiers get enforced:
-- A member can read their own membership row, and nobody else's.
create policy "Members read own membership"
on public.memberships for select
to authenticated
using ( (select auth.uid()) = user_id );
-- A lesson is readable only if the member's tier is high enough
-- and their membership has not lapsed.
create policy "Members read lessons for their tier"
on public.lessons for select
to authenticated
using (
exists (
select 1
from public.memberships m
where m.user_id = (select auth.uid())
and (m.expires_at is null or m.expires_at > now())
and case public.lessons.required_tier
when 'free' then true
when 'pro' then m.tier in ('pro', 'vip')
when 'vip' then m.tier = 'vip'
else false
end
)
);
Read the second policy as a sentence: a lesson is visible if a membership row exists for the current user, has not expired, and carries a tier at least as high as the lesson requires.
A signed-out visitor is a different case, and it's worth being precise because the grants were revoked from anon and never restored; Postgres raises a permission error before any policy runs. That is a failed request rather than an empty list, so the route has to catch it and return a 401.
If you would rather show free lessons to visitors who have not signed in, grant select to anon and add a policy that exposes only the free tier.
Test this before building anything on top of it. Sign in as a free member and query the lessons table directly from the Supabase SQL editor as that user. If a Pro lesson comes back, the policy is wrong, and nothing you build on top of it will fix that.
Finish this step with a lessons table that returns different row sets for a free member and a Pro member, with no application code involved.
3. Query from a Route Handler with the publishable key
With the gate in the database, the application code gets noticeably boring, which is the sign it is working.
The route asks for every lesson and lets the policy decide what that means:
// app/api/lessons/route.ts
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { createServerClient } from '@supabase/ssr'
export async function GET() {
const jar = await cookies()
// Publishable key, not the secret one. The database decides what
// this session is allowed to see.
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL as string,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY as string,
{
cookies: {
getAll: () => jar.getAll(),
setAll: (list) => {
try {
list.forEach(({ name, value, options }) =>
jar.set(name, value, options)
)
} catch {
// Called from a context that cannot set headers.
}
},
},
}
)
// No tier filter here on purpose: the policy applies it, so a bug
// in this file cannot widen access.
const { data, error } = await supabase
.from('lessons')
.select('id, title, body, required_tier')
if (error) {
return NextResponse.json({ error: 'Could not load lessons' }, { status: 500 })
}
return NextResponse.json({ lessons: data })
}
The absence of a tier filter in this file is deliberate, not an oversight. If the filter lived here, a future refactor could drop it and quietly publish paid content, and nothing would fail. With the rule in the database, the same mistake returns the same restricted list.
Note the key too. This route uses the publishable key, so the query runs as the signed-in member. Reaching for the secret key here would bypass every policy you just wrote and return all lessons to everyone.
You finish this step with a route that returns different results for different members without containing a single conditional about tiers.
4. Change tiers from a trusted path only
Something has to promote a member when they pay, and that something cannot be the member. This is the one place the secret key is appropriate, because writing to the memberships table is exactly the operation that policies are stopping everybody else from doing.
In practice, this is a webhook from your payment provider: it verifies the signature, maps the customer back to a Supabase user, and updates the tier and expiry. Verify the signature before you trust the payload, because an unverified webhook endpoint is a public API for granting yourself a VIP membership.
Keep this handler small and separate from the reading routes, so the secret key appears in exactly one file that is easy to audit. Never import it into anything a page renders.
By the end of this step, you should be able to trigger a test payment event and watch the member's tier change in the table without touching the dashboard.
5. Handle expiry as a state, not a cron job
Memberships lapse, and the tempting design is a scheduled job that sweeps expired rows and downgrades them. That job will eventually fail to run, and when it does, expired members keep their access.
The policy above avoids that by checking expires_at at read time. Access ends the moment the timestamp passes, with no job involved, because every query answers the question "is this membership current" rather than once a night.
Keep the expired row rather than deleting it. It is the record of what somebody used to have; it makes renewal a single update, and it means a lapsed member who returns sees their history rather than an empty account.
You finish this step with a membership that expires correctly even if every background process you own stops running.
What causes Supabase membership gating to fail?
Nearly every failure here is one of two things: a query returning more than it should because a rule is missing, or returning nothing because a grant is. Both look like application bugs and neither is.
These four cover the cases worth checking first.
Members can see content above their tier
Cause: Either RLS was never enabled on the table, or the query is running with the secret key. A table without RLS is readable by any role with a grant, and the secret key bypasses policies by design, so both produce the same symptom: a gate that isn't gating.
Fix: Confirm RLS is enabled on every table in the exposed schema, then check which key the failing route uses to construct its client. The reliable test is to query the table as a signed-in free member directly in the SQL editor rather than through your app, since that isolates the database rule from everything you wrote around it.
If the SQL editor returns restricted rows, the policy is the problem; if it does not, the key is.
Signed-in members see nothing at all
Cause: RLS is enabled, and no policy grants access, or the grants were revoked and not restored. Once RLS is on, no data is accessible through the API with a publishable key until a policy allows it. Hence, a table with policies but no grant, or grants but no policy, returns an empty set either way.
Fix: Check both halves, not just one. Confirm the role has a grant on the table, and confirm a policy exists for the operation being attempted, remembering that a select policy does nothing for an insert.
An empty result with no error is the normal appearance of this problem, which is why it gets misread as a data issue rather than an access one.
The session is missing inside the Route Handler
Cause: The Supabase client was created without wiring up cookies, so the request runs as an anonymous visitor even though the browser is signed in. Every policy that depends on auth.uid() then evaluates against nothing.
Fix: Use the server client from @supabase/ssr with both getAll and setAll implemented against the request's cookie jar, since a client that can read cookies but not write them cannot refresh an expired session and will start failing once the first token ages out.
Log the resolved user ID once during development to confirm the route sees the member you expect. This failure is easy to misdiagnose because it presents exactly like the empty-result case above: the query succeeds, the policy evaluates honestly, and the answer for an anonymous caller is legitimately nothing.
The build fails after adding the membership routes
Cause: An export const runtime = 'edge' directive in one of the new files. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js edge runtime.
Fix: Remove the line and redeploy, since Route Handlers already run on the Workers runtime without it. Search the whole project, not just the file you last edited, because one directive anywhere will fail the build.
It's worth checking here first, because Supabase examples are often written for other hosts where the directive is expected or harmless, so a copied handler can bring it in unnoticed.
Webflow's own bring-your-own-app page also still tells Next.js readers to add it, which means the instruction you followed may itself be the cause rather than anything you wrote.
What you can build next with Supabase and Webflow
Once the tier gate holds in the database, the same policy pattern extends to everything else a membership implies: per-member file access through Storage, comments only members can post, and an admin view that uses a policy on a role claim rather than a separate application.
For more depth on the payment side, our authentication and payments guide covers subscription billing against a similar account model. For the no-code connection routes, see the Webflow and Supabase integration.
Frequently asked questions
Is it safe to put the Supabase publishable key in the browser?
Yes, and Supabase documents it as safe to expose in a web page, mobile app or source code. That safety depends entirely on Row Level Security being enabled with policies in place, because the key's privileges are low by design and the database is what enforces access.
What is the difference between the anon key and the publishable key?
Naming, mostly. Supabase documents anon as the legacy version of the publishable key and service_role as the legacy version of the secret key. Both continue to work alongside the newer keys, but Supabase says the legacy keys will be deprecated by the end of 2026, so new builds should use the publishable and secret keys.
Why not just check the tier in my application code?
Because that check only protects the paths that run it. A policy in the database applies to every query from every client, so a new route added later inherits the rule instead of needing to remember it.
Do policies replace grants?
No, and assuming they do is a common way to leave a table more open than intended. Adding a policy does not remove an existing grant. Revoke the grants you do not want, then grant back only what each role needs alongside the policies.
How should expired memberships be handled?
Check the expiry inside the policy rather than downgrading rows on a schedule. Access then ends when the timestamp passes, with no dependency on a background job that might not run.




