Chuyển đến nội dung chính
友田 陽大
Databases & 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前提で公式準拠の実コードで解説します。

Published
Reading time
9 min read
Author
友田 陽大
Chia sẻ

「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アプリ全体を静かに守り続けます。次は、それでも詰まったときのトラブルシューティングへ。

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

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

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.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc