メインコンテンツへスキップ
友田 陽大
データベース・RLS
Next.js
Supabase
RLS
TypeScript
セキュリティ

Next.js App RouterでSupabase RLSを正しく効かせる:@supabase/ssr・サーバー/ブラウザクライアント・JWT伝播の完全ガイド

「Supabase RLSを書いたのにNext.jsだとデータが空で返る/全部見える」の原因は、ほぼクライアントの作り方です。@supabase/ssrのcreateBrowserClient/createServerClient、cookieのgetAll/setAll、middlewareのgetUser/getClaims、JWTがauth.uid()に伝わる仕組み、service_roleをクライアントで使ってはいけない理由まで、App Router前提で公式準拠の実コードで解説します。

公開日
読了時間
9分
著者
友田 陽大
シェア

「Supabaseのダッシュボードでは正しく動くRLSが、Next.jsアプリからだとデータが空で返ってくる」「逆に、本番で全ユーザーのデータが見えてしまった」——この2つは、Supabase + Next.jsで最も多い詰まりどころです。そして原因のほぼ全ては、RLSポリシーそのものではなく、Next.js側の『Supabaseクライアントの作り方』にあります

RLSは「リクエストを送ってきたユーザーは誰か」を auth.uid() で見て行を絞ります。ということは、そのユーザーのJWT(トークン)がDB接続まで正しく運ばれていなければ、RLSは判定材料を失います。JWTを運ばないクライアントで繋げば「未認証(anon)」扱いになって空が返り、service_role キーで繋げばRLSをバイパスして全部見える。どちらも「RLSのバグ」ではなく「結合のミス」です。

この記事は、Next.js App Router で RLS を確実に効かせるための、クライアント生成とJWT伝播の正しい型を、公式の @supabase/ssr 前提で解説します。題材は、私がリアルタイム試合記録アプリExpo + Next.js + Supabase のモノレポ(69テーブル全RLS・280ポリシー)で構築した実装です。内容はSupabase公式: Server-Side Auth (Next.js)RLS公式(2026年6月時点)に忠実です。

前提:RLSポリシーの書き方そのものはRLS入門で扱いました。本記事は「正しく書けたRLSを、Next.jsから正しく呼ぶ」結合層に集中します。


1. 一枚の図で理解する:JWTはどこを通ってauth.uid()になるのか

まず全体像です。RLSが効くとは、次の鎖が1箇所も切れずに繋がっていることを意味します。

ブラウザ(Cookieにセッション)
  → Next.jsサーバー(middleware/Server Component)
    → @supabase/ssr が Cookie から JWT を読む
      → PostgREST へ Authorization: Bearer <JWT> として送る
        → Postgres が JWT を検証し authenticated ロールで実行
          → ポリシー内の auth.uid() が「そのユーザーのID」を返す
            → 行が正しく絞られる ✅

この鎖のどこかで JWT が落ちると、Postgres は「トークン無し=anon」として実行し、auth.uid()null を返します。using ((select auth.uid()) = user_id)null = user_id(常に偽)になり、1行も返らない。これが「空で返る」の正体です。

つまり Next.js 側の仕事は1つ——ブラウザのCookieにあるセッションを、サーバーでのDB接続まで途切れさせずに運ぶこと。@supabase/ssr はそのための公式パッケージです。


2. 2種類のクライアントを作り分ける

App Routerでは実行環境が「ブラウザ」と「サーバー」に分かれます。Supabaseクライアントも環境ごとに別物を作ります。混ぜると壊れます。

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_を付けない。サーバーでも極力使わない

ブラウザ用: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!,
  );

サーバー用:Server Components / Route Handlers / Server Actions から使う

サーバー側はCookieを読み書きしてセッションを橋渡しします。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が面倒を見る)
          }
        },
      },
    },
  );
};

なぜ anon(publishable)キーをサーバーでも使うのか:このサーバークライアントは「サーバー上で動くが、ユーザーのJWTを背負って authenticated として接続する」ものです。だから service_role ではなく anon キーで正しい。RLSはちゃんと効きます。service_role を使うとRLSを捨てることになります(§5)。


3. middleware:毎リクエストでトークンを更新し、Cookieに書き戻す

@supabase/ssr の肝は middleware です。ここで毎リクエスト、期限切れトークンを更新し、新しいセッションCookieをレスポンスに書き戻します。これを置かないと、トークンが切れた瞬間に「空で返る」が再発します。

// 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).*)"],
};

getUser() / getClaims() を使い、getSession() を信頼しない

ここはセキュリティの分かれ目です。Supabase公式はサーバーコードで getSession() を信頼するなと明言しています——「セッションはローカルストレージから読まれるだけで、Authサーバーに対して再検証されない」(Supabase公式)。

API何をするかサーバーで使う?
getSession()Cookie/localStorageの中身をそのまま返す(未検証❌ 認可判断に使わない
getUser()Authサーバーに問い合わせ、ユーザーをサーバー確認✅ 確実だがネットワーク往復あり
getClaims()JWT署名を公開鍵で検証してクレームを返す✅ 署名検証ベースで高速

最新のSupabase(非対称JWT=公開鍵で検証可能)では、getClaims()プロジェクトの公開鍵に対してJWT署名を検証するため、毎回Authサーバーに問い合わせずに安全な認可判断ができます。getUser() は「いま確実なユーザー情報」が欲しいときに使います。getSession() の戻り値を認可の根拠にしてはいけません。


4. 実際に使う:Server Component と Server Action

ここまで整えば、あとは普通に書くだけでRLSが効きます。アプリ側で where user_id = ... を書く必要はありません——RLSがやってくれます。

// 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");
}

設計のポイント:アプリ層でも user.id を入れ、Zodで検証する。でもそれは「行儀の良さ」であって、**最後に守るのはDBのRLS(WITH CHECK)**です。アプリの検証を通り抜けても、RLSが他人の user_id を持つ行の作成を弾く。これが「アプリの善意に依存しない」多層防御です。


5. 最大の禁忌:service_role キーをクライアントで使わない

最後に、最も重大な事故を1章割いて潰します。

service_role キーは RLS を完全にバイパスします。 ブラウザ・Client Component・NEXT_PUBLIC_ 環境変数・公開リポジトリに絶対に置かないでくださいSupabase公式)。

なぜこれが頻発するか。開発中に「RLSのせいでデータが取れない」とき、service_role で繋ぐと一発で取れてしまうからです。これは問題解決ではなく問題の隠蔽で、RLSを書いた全努力を無に帰します。正しくは「なぜ anon 接続で取れないのか(=JWTが運ばれていない)」をトラブルシューティングの手順で直すことです。

service_role を使ってよいのは、RLSでは表現できない管理操作(バッチ、Webhook受信、cron)をサーバー専用の隔離された経路で行うときだけ。その場合も:

  • NEXT_PUBLIC_ を付けない環境変数に置く(クライアントバンドルに入れない)
  • Server Action / Route Handler の中だけで生成し、ブラウザに渡さない
  • 使う箇所を最小化し、可能ならRLS+security definer関数で代替する
// 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 } },
  );

import "server-only" を先頭に置くと、誤ってClient Componentからimportした瞬間にビルドが失敗します。「絶対にクライアントに出さない」を規律ではなく型・ビルドで強制する——これが事故を構造的に防ぐ設計です。


まとめ:RLSを活かすも殺すも「結合」次第

  • Next.jsで「RLSが効かない/空で返る」は、ほぼクライアント生成の誤り。RLSのバグではない。
  • @supabase/ssrブラウザ用(createBrowserClient)とサーバー用(createServerClient)を作り分け、CookieのgetAll/setAllでセッションを橋渡しする。
  • middlewareで getUser()/getClaims() を毎回呼び、トークンを更新してCookieに書き戻す。getSession() の戻り値を認可に使わない。
  • アプリ側で where を書かなくてもRLSが絞る。だが書き込みはWITH CHECKで最終防衛する。
  • service_role をクライアントに出さないimport "server-only" で構造的に禁止する。

結合が正しければ、RLSは「サーバーの書き忘れ」に一切依存しない最終防衛線として、Next.jsアプリ全体を静かに守り続けます。次は、それでも詰まったときのトラブルシューティングへ。

一次情報(必ず最新を確認してください)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

お困りごとはありませんか?

設計から実装・運用まで、一人 × 生成AI で伴走します

この記事のような実装を、要件定義から本番運用まで一気通貫で。まずは30分の無料技術相談から、状況をお聞かせください。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい