1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SupabaseのRLSをNext.js App Routerで実装して2回やらかした話

1
Posted at

はじめに

takkenai.jpの開発でSupabaseのRow Level Security(RLS)をNext.js App Routerに導入した際、同じ誤りに2回踏みました。「設定したはずなのにデータが取れない」「リリース後にエラーレポートが時」——同じハマり方をする人がいれば少しでも参考になればと思います。


最初に知っておくべきRLSの基本

SupabaseのRLSは、PostgreSQLのRow Level Securityをラップしたものです。有効にすると、ポリシーを書かない限り誰もそのテーブルにアクセスできなくなります。デフォルトは「全拒否」です。

App Routerでは主に2つのクライアントを使い分けます:

// Server Component / Route Handler用(ユーザー認証に必要)
import { createServerClient } from '@supabase/ssr';

// Client Component用
import { createBrowserClient } from '@supabase/ssr';

ミス1:anonキーで認証済みリソースにアクセスしようとした

何が起きたか

RLSを有効にした後、Server Componentからデータを取得しようとしたら空配列が返ってきた。

// これが問題のコード
export default async function Page() {
  const supabase = createClient(); // サーバーサイドのつもりで作ったのに
  const { data } = await supabase.from('articles').select('*');
  // data === [] ... なぜ?
}

原因

Server Component内でcreateClient()と書いていたつもりが、実際にはBrowser環境で初期化された山手がエクスポートされていたcookies()を渡さず山手をインポートしていたので、anonキーで動作していました。

// lib/supabase/server.ts がこうなっていた
export function createClient() {
  return createBrowserClient( // ← 問題!serverではなく browser
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

修正

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

export function createClient() {
  const cookieStore = cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return cookieStore.getAll(); },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          );
        },
      },
    }
  );
}

教訓: server.tsclient.tsを分けて作っても、インポートする場所を間違えると全沢意になる。


ミス2:service_roleキーを従来通り使ってRLSをバイパスした

何が起きたか

最初の問題を修正したものの、次は「管理機能の為に全データを取得したい」シーンでわからずservice_roleキーをRoute Handlerにハードコードしてしまいました。

// app/api/admin/route.ts (不適切な実装)
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // ← RLSを完全無視
);

何がまずいのか

service_roleキーはRLSを完全にバイパスします。これ自体は必要なケースがありますが、問題は2つでした:

  1. Route Handler自体に認証チェックを増やしたない→ログインしていないユーザーでもアクセスできた
  2. RLSの恵沢を自分で捨てている→セキュリティ設計が崩壊する

正しいアプローチ

大半のケースではservice_roleは不要。ユーザーのセッションを引き継いだServerコンテキストを使うべきです

// 正しいRoute Handler
export async function GET(request: Request) {
  const supabase = createServerClient(...);
  
  // まず認証チェック
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return new Response('Unauthorized', { status: 401 });
  
  // RLSがユーザーの権限で自動フィルタリングしてくれる
  const { data } = await supabase.from('articles').select('*');
  return Response.json(data);
}

教訓: RLSは「データ層の認証」。service_roleでバイパスするのは、鍵をかけたのに鍵をフロントドアに山積みするようなもの。


まとめ

ポイント 内容
createBrowserClient vs createServerClient Server Component内で間違えると認証情報を読まずanonキー動作
service_roleキーの使いどころ RLSが完全無視される。Route Handler認証チェックも并用すること
RLSの正しい層位づけ アプリケーション層でなくデータ層のセキュリティと組み合わせる

両方とも「動いているように見える」でデバッグが難しかったです。同じハマり方をする方の参考になれば幸いです。


実際に使っているサービスはtakkenai.jp——宅建試験のAI学習サービスです。ユーザーごとの学習履歴管理にこのスタックを使っています。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?