Skip to main content
友田 陽大
Databases & RLS
Next.js
Supabase
RLS
TypeScript
セキュリティ

Making Supabase RLS work correctly in the Next.js App Router: a complete guide to @supabase/ssr, server/browser clients, and JWT propagation

The cause of 'I wrote Supabase RLS but in Next.js data comes back empty / everything is visible' is almost always how the client is created. Premised on the App Router, with official-compliant real code it explains @supabase/ssr's createBrowserClient/createServerClient, the cookie getAll/setAll, middleware's getUser/getClaims, the mechanism by which the JWT reaches auth.uid(), and why you must not use service_role on the client.

Published
Reading time
9 min read
Author
友田 陽大
Share

"RLS that works correctly in the Supabase dashboard returns empty data from a Next.js app," "conversely, in production all users' data became visible" — these two are the most common sticking points with Supabase + Next.js. And almost all of the cause is not the RLS policy itself but the Next.js side's 'how the Supabase client is created.'

RLS narrows rows by looking at "who is the user that sent the request" with auth.uid(). That means if that user's JWT (token) isn't correctly carried all the way to the DB connection, RLS loses its material for judgment. Connect with a client that doesn't carry the JWT and you're treated as "unauthenticated (anon)" and empty is returned; connect with the service_role key and you bypass RLS and see everything. Neither is "an RLS bug" but "an integration mistake."

This article explains, premised on the official @supabase/ssr, the correct pattern of client creation and JWT propagation to make RLS reliably work in the Next.js App Router. The material is my implementation building a real-time match-recording app as an Expo + Next.js + Supabase monorepo (all RLS on 69 tables, 280 policies). The content is faithful to Supabase official: Server-Side Auth (Next.js) and the RLS official docs (as of June 2026).

Premise: writing the RLS policy itself was handled in RLS getting started. This article concentrates on the integration layer of "correctly calling correctly-written RLS from Next.js."


1. Understand it in one diagram: where does the JWT pass through to become auth.uid()

First, the big picture. RLS working means the following chain is connected without a single break.

Browser (session in Cookie)
  → Next.js server (middleware/Server Component)
    → @supabase/ssr reads the JWT from the Cookie
      → sends to PostgREST as Authorization: Bearer <JWT>
        → Postgres verifies the JWT and runs as the authenticated role
          → auth.uid() in the policy returns "that user's ID"
            → rows are correctly narrowed ✅

If the JWT drops somewhere in this chain, Postgres runs as "no token = anon" and auth.uid() returns null. using ((select auth.uid()) = user_id) becomes null = user_id (always false), and not a single row is returned. This is the true identity of "returns empty."

So the Next.js side's job is one — carry the session in the browser's Cookie to the DB connection on the server without interruption. @supabase/ssr is the official package for that.


2. Create two kinds of clients separately

In the App Router, the execution environment splits into "browser" and "server." The Supabase client too is a different thing per environment. Mix them and it breaks.

npm install @supabase/supabase-js @supabase/ssr
# .env.local — クライアントに出てよいのは publishable(anon) キーだけ
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key
# SUPABASE_SERVICE_ROLE_KEY=... ← NEXT_PUBLIC_を付けない。サーバーでも極力使わない

For the browser: use from Client Components

// lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";

export const createClient = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
  );

For the server: use from Server Components / Route Handlers / Server Actions

The server side reads and writes Cookies to bridge the session. The key point is implementing both getAll/setAll.

// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export const createClient = async () => {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          // Server Componentからの呼び出しでは書き込みが失敗し得る。
          // middlewareがセッションを更新するので、ここはtry/catchで握る。
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            );
          } catch {
            // Server Componentでは無視してよい(middlewareが面倒を見る)
          }
        },
      },
    },
  );
};

Why use the anon (publishable) key on the server too: this server client is "runs on the server, but connects as authenticated carrying the user's JWT." So the anon key, not service_role, is correct. RLS works properly. Using service_role means throwing away RLS (§5).


The crux of @supabase/ssr is the middleware. Here you refresh expired tokens every request and write the new session Cookie back to the response. Without this, "returns empty" recurs the moment the token expires.

// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          // リクエストとレスポンスの両方にCookieを反映する(重要)
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value),
          );
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options),
          );
        },
      },
    },
  );

  // ⚠️ これを必ず呼ぶ。トークンを更新し、JWT署名を検証する。
  await supabase.auth.getUser();

  return response;
}

export const config = {
  // 静的アセットを除外して、ページ遷移ごとに走らせる
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Use getUser() / getClaims(), don't trust getSession()

This is the divide of security. The Supabase official explicitly says don't trust getSession() in server code — "the session is only read from local storage and not re-verified against the Auth server" (Supabase official).

APIWhat it doesUse on server?
getSession()returns the Cookie/localStorage contents as-is (unverified)❌ don't use for authorization judgment
getUser()queries the Auth server and server-confirms the user✅ certain but with a network round trip
getClaims()verifies the JWT signature with the public key and returns the claims✅ signature-verification-based and fast

In the latest Supabase (asymmetric JWT = verifiable with a public key), getClaims() verifies the JWT signature against the project's public key, so it can make a safe authorization judgment without querying the Auth server each time. Use getUser() when you want "certain user info right now." Don't make getSession()'s return value the basis of authorization.


4. Actually use it: Server Component and Server Action

Once this is set up, after that just write normally and RLS works. You don't need to write where user_id = ... on the app side — RLS does it.

// app/dashboard/page.tsx — Server Component
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const supabase = await createClient();

  // 認可は getUser()/getClaims() で確認する
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  // ⬇ where句を書いていないのに、RLSが「自分の行だけ」に絞る
  const { data: matches, error } = await supabase
    .from("matches")
    .select("id, opponent, score")
    .order("created_at", { ascending: false });

  if (error) throw error; // RLSで弾かれた場合の挙動は次の章で扱う

  return (
    <ul>
      {matches?.map((m) => (
        <li key={m.id}>
          {m.opponent}: {m.score}
        </li>
      ))}
    </ul>
  );
}
// app/matches/actions.ts — Server Action
"use server";

import { createClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";
import { z } from "zod";

const CreateMatch = z.object({
  opponent: z.string().min(1).max(100),
  score: z.string().regex(/^\d+-\d+$/),
});

export async function createMatch(formData: FormData) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) throw new Error("unauthenticated");

  // 外部入力はサーバー境界でZod検証してから渡す
  const input = CreateMatch.parse({
    opponent: formData.get("opponent"),
    score: formData.get("score"),
  });

  // user_idを明示的に入れる。RLSのWITH CHECK ((select auth.uid()) = user_id) が
  // 「他人のuser_idを差し込む」のをDB側で弾く(多層防御)
  const { error } = await supabase
    .from("matches")
    .insert({ ...input, user_id: user.id });
  if (error) throw error;

  revalidatePath("/dashboard");
}

The design point: put user.id on the app layer too and validate with Zod. But that's "good manners," and what protects last is the DB's RLS (WITH CHECK). Even if it slips past the app's validation, RLS rejects creating a row with someone else's user_id. This is the defense-in-depth that "doesn't depend on the app's goodwill."


5. The biggest taboo: don't use the service_role key on the client

Finally, I'll devote a chapter to crushing the most serious accident.

The service_role key completely bypasses RLS. Never put it in the browser, a Client Component, a NEXT_PUBLIC_ environment variable, or a public repository (Supabase official).

Why does this happen frequently. Because during development, when "data can't be fetched because of RLS," connecting with service_role fetches it in one go. This is not problem-solving but concealing the problem, nullifying all the effort of writing RLS. The correct way is to fix "why can't it be fetched on an anon connection (= the JWT isn't carried)" with the troubleshooting procedure.

You may use service_role only when performing admin operations RLS can't express (batch, webhook reception, cron) on a server-only isolated path. Even then:

  • put it in an environment variable without NEXT_PUBLIC_ (don't put it in the client bundle)
  • create it only inside a Server Action / Route Handler, and don't pass it to the browser
  • minimize where it's used, and substitute with RLS + a security definer function if possible
// lib/supabase/admin.ts — サーバー専用。import元を厳格に管理する
import "server-only"; // クライアントからのimportをビルド時に禁止する
import { createClient } from "@supabase/supabase-js";

export const createAdminClient = () =>
  createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!, // NEXT_PUBLIC_ ではない
    { auth: { persistSession: false } },
  );

Place import "server-only" at the top and the build fails the moment it's mistakenly imported from a Client Component. Enforcing "never expose to the client" not by discipline but by type/build — this is the design that structurally prevents the accident.


Conclusion: whether RLS lives or dies depends on "integration"

  • "RLS not working / returning empty" in Next.js is almost always a client-creation mistake. Not an RLS bug.
  • With @supabase/ssr, create the browser one (createBrowserClient) and the server one (createServerClient) separately, and bridge the session with the Cookie getAll/setAll.
  • Call getUser()/getClaims() every time in middleware, refresh the token, and write it back to the Cookie. Don't use getSession()'s return value for authorization.
  • RLS narrows even without writing where on the app side. But defend writes finally with WITH CHECK.
  • Don't expose service_role to the client. Structurally forbid it with import "server-only".

If the integration is correct, RLS keeps quietly protecting the whole Next.js app as a last line of defense that doesn't depend at all on "a forgotten write on the server." Next, on to the troubleshooting for when you still get stuck.

Primary sources (always confirm the latest)

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading