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