Next.js App Router × Supabase:anon key は隠さなくていい。守るのは RLS、service role key はサーバー専用にする
Supabase の anon key はブラウザに出る前提の鍵で、NEXT_PUBLIC_ に置いても問題ない。守るべきは RLS。一方 service role key は RLS をバイパスする秘密なので、サーバー専用に閉じる。この役割分担を整理した。
Supabase を Next.js で使い始めると、「anon key を NEXT_PUBLIC_ でブラウザに出して大丈夫なのか」と不安になる。結論から言うと、anon key は出していい。守るべきはそこではなく RLS(Row Level Security)だ。逆に絶対に出してはいけないのが service role key で、こちらをサーバー専用に閉じる。2 つの鍵の役割を取り違えると、設計を間違える。
まず前提を正す:2 つの鍵の役割
Supabase はプロジェクトに 2 種類の鍵を発行する。性質が正反対なので、ここを最初に押さえる。
- anon key:ブラウザに公開される前提で設計された鍵。
NEXT_PUBLIC_SUPABASE_ANON_KEYとして配っても問題ない。この鍵だけでは何でもできるわけではなく、アクセス制御は RLS(Row Level Security) が担う。anon key は「どのテーブルに、RLS の許す範囲で問い合わせていいか」の入口にすぎない。 - service role key:RLS を完全にバイパスする高権限の秘密。これを持つと全行を読み書きできる。クライアントに出した瞬間、DB が丸ごと公開されたのと同じになる。サーバー専用に閉じるのは当然の前提で、
NEXT_PUBLIC_を付けてはいけない。
よくある誤解が「anon key を隠せば安全」という発想だ。これは逆で、anon key を隠しても RLS が緩ければデータは漏れるし、anon key が見えていても RLS が正しければ守られる。セキュリティの本体は鍵の秘匿ではなく RLS のポリシーにある。
anon key + RLS:クライアントから直接触る通常パターン
ユーザー認証やリアルタイム更新があるなら、これが基本形になる。anon key はブラウザに出し、RLS でアクセス範囲を縛る。
// lib/supabaseClient.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // ブラウザに出てOK
);
-- 公開記事だけ anon に読ませる RLS ポリシー
alter table articles enable row level security;
create policy "public can read published articles"
on articles for select
using (published = true);
このポリシーがあれば、anon key を持つクライアントは published = true の行しか取れない。鍵が見えていることは問題にならない。
サーバー専用クライアント:RLS を越える操作を閉じ込めたいとき
では service role key の出番はどこか。RLS では表現しきれない特権操作(管理画面での全件集計、ユーザーをまたいだ書き込み、バッチ処理など)をサーバー側に閉じ込めたいときだ。あわせて「クライアントから直接 DB を触らせず、Route Handler / Server Action を必ず経由させる」というアーキテクチャを選ぶ場合にも使う。
// lib/supabaseAdmin.ts —— サーバーからしか import しない
import { createClient } from '@supabase/supabase-js';
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!, // NEXT_PUBLIC_ なし
process.env.SUPABASE_SERVICE_ROLE_KEY!, // RLS をバイパスする秘密
);
// app/api/articles/route.ts(Route Handler = サーバー専用)
import { supabaseAdmin } from '@/lib/supabaseAdmin';
export async function GET() {
const { data } = await supabaseAdmin
.from('articles')
.select('*')
.eq('published', true);
return Response.json(data);
}
SUPABASE_SERVICE_ROLE_KEY に NEXT_PUBLIC_ を付けない。これでバンドルに混ざらず、ブラウザには届かない。Vercel の環境変数に登録するときも同様に NEXT_PUBLIC_ なしで入れる。
service role key を使うときの注意:RLS が効いていない
service role key を使うクライアントでは RLS が一切働かない。anon key なら弾かれたデータも全部読めて、書ける。「サーバーにしか届かないから大丈夫」と油断すると、クエリの絞り込み漏れがそのまま漏洩・破壊につながる。RLS という安全網を自分で外している状態だと意識して、条件は明示的に書く。
// ❌ RLS が効かない状態で全件取得(絞り忘れがそのまま事故になる)
const { data } = await supabaseAdmin.from('users').select('*');
// ✅ 必要な条件を必ず明示する
const { data } = await supabaseAdmin
.from('articles')
.select('*')
.eq('published', true)
.order('date', { ascending: false });
どちらを選ぶか
| anon key + RLS | service role key(サーバー専用) | |
|---|---|---|
| 鍵の公開 | ブラウザに出してOK | 絶対に出さない |
| アクセス制御 | RLS が担保 | RLS をバイパス。自分で絞る |
| 向いている場面 | 認証・リアルタイム・クライアント直アクセス | 特権操作・全件処理・DB を直接触らせない構成 |
迷ったら anon key + RLS が既定。これが Supabase の想定する標準的な使い方だ。service role key は「RLS を越える必要がある処理だけ」をサーバーに閉じ込めるための道具と捉えるといい。
- ISR・SSG・サーバーコンポーネントでデータがサーバー完結するなら、Route Handler / Server Action 経由にしてクライアントから DB を見せない構成が組める
- ISR のキャッシュと組み合わせれば、DB アクセスをビルド時・再検証時だけに抑えて負荷を下げられる
- どちらの鍵を使うにせよ、RLS は必ず有効にしておく。service role 側のミスがあっても、anon 経路は RLS で守られている状態を保てる