結論
supabaseのanon keyからDB操作権限を全剝奪した。
人々は「こいつはBaaSを運用できない未熟者だ」と罵るだろう。
-- public スキーマ利用権限の取り消し
revoke usage on schema public from anon;
-- テーブル、関数、シーケンスの全権限の取り消し
revoke all privileges on all tables in schema public from anon;
revoke all privileges on all routines in schema public from anon;
revoke all privileges on all sequences in schema public from anon;
-- 今後のデフォルト権限からanonを除外
-- postgresロールがpublicスキーマに作成するオブジェクトに対し、anonへの権限付与を停止
alter default privileges for role postgres in schema public
revoke all on tables from anon;
alter default privileges for role postgres in schema public
revoke all on routines from anon;
alter default privileges for role postgres in schema public
revoke all on sequences from anon;
なぜ私はBaaSの喉元に刃を突きつけたのか
SupabaseをはじめとするBaaS(Backend as a Service)の魅力は、なんといってもその手軽さにある。クライアントサイドから直接データベースを操作できるAPIが自動で生成され、爆速でアプリケーションを構築できる。
しかし、その手軽さは諸刃の剣だ。anon key
はクライアントサイドに埋め込むことが前提のキーであり、言ってしまえば世界中に公開されているに等しい。
もちろん、SupabaseにはRLS(Row Level Security)という強力な飛び道具がある。「正しく設定すれば安全だ」という意見は至極もっともだ。しかし、人間は必ずミスを犯す。
- 新しく追加したテーブルにRLSポリシーを設定し忘れた
- ポリシーのSQLに意図しない脆弱性を生んでしまった
- 開発中に一時的にRLSを無効化し、戻し忘れた
- 悪意あるユーザーにフロント側バリデーションが突破された
たった一つの「うっかり」が、致命的な情報漏洩に繋がりかねない。私は、その恐怖に耐えられなかった。BaaSの利便性よりも、データの安全性を絶対的に優先したかったのだ。
では、どうやってデータを操作するのか? 答えはフロントエンドにある
クライアントからDBへの直接アクセスを禁じた今、どこでデータを取得するのか。
Supabase Edge Functionsを使うことが公式のプラクティスだろう。
いや、その必要はない。我々が普段使っているフレームワークに、その答えはすでに用意されているからだ。
Next.jsのServer Componentsや、React Routerのloader/action。これらのサーバーサイドで実行される領域を活用するのだ。
アーキテクチャはこうだ。
- クライアント(ブラウザ)からのリクエストは、まずアプリケーションサーバー(Next.jsなど)に到達する。
- Server Componentsやloader関数といった「サーバーサイドの領域」で、
service_role key
を使い、安全にSupabaseへアクセスしてデータを取得する。 - 取得したデータをpropsやloader dataとして、HTMLと一緒にクライアントに送信する。
DB操作のロジックはすべて、我々の管理下にあるサーバーサイドに閉じ込めることができるのだ。
実装例
具体的なコードを見てみよう。
Case 1: Next.js (App Router) のServer Componentsを使う
Next.jsのApp Routerでは、コンポーネントはデフォルトでServer Componentsとなる。ここで直接DBにアクセスする。
データの書き込み (Server Action & Form)
- バリデーションとDB操作を定義 (app/articles/actions.ts)
'use server'
import { createClient } from '@supabase/supabase-js'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
// 入力データのスキーマ定義 by zod
const articleSchema = z.object({
title: z.string().min(1, 'タイトルは必須です').max(100, '100文字以内で入力してください'),
})
export async function createArticle(formData: FormData) {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
// 1. バリデーション
const validatedFields = articleSchema.safeParse({
title: formData.get('title'),
})
if (!validatedFields.success) {
// エラーメッセージを返す
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
// 2. DBへ挿入
const { error } = await supabase
.from('articles')
.insert({ title: validatedFields.data.title })
if (error) {
return { errors: { db: 'データベースエラーが発生しました。' } }
}
// 3. キャッシュを更新して一覧に即時反映
revalidatePath('/articles')
}
- フォームコンポーネント (app/articles/_components/new-article-form.tsx)
'use client'
import { createArticle } from '../actions'
export function NewArticleForm() {
return (
// Server Actionを直接formのactionに渡す
<form action={createArticle}>
<input type="text" name="title" required />
<button type="submit">投稿</button>
{/* エラー表示処理は省略 (useFormState等で実装) */}
</form>
)
}
Case 2: React Router の loader & action を使う
データの読み取りはloaderで、書き込みはactionで実装します。
データの読み書き (app/routes/articles.tsx)
import { redirect } from "react-router"; // or your server adapter
import { useLoaderData, Form } from "react-router-dom";
import { createClient } from "@supabase/supabase-js";
import { z } from 'zod'
// loader関数 (データの読み取り)
export async function loader() {
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const { data } = await supabase.from("articles").select("*");
return data;
}
// action関数 (データの書き込み)
export async function action({ request }: { request: Request }) {
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const formData = await request.formData();
// 1. バリデーション
const articleSchema = z.object({ title: z.string().min(1) });
const validatedFields = articleSchema.safeParse({ title: formData.get('title') });
if (!validatedFields.success) {
return json({ error: "タイトルは必須です。" }, { status: 400 });
}
// 2. DBへ挿入
const { error } = await supabase.from('articles').insert({ title: validatedFields.data.title });
if (error) {
throw new Response("データベースエラー", { status: 500 });
}
// 3. 成功したらリダイレクト
return redirect("/articles");
}
// コンポーネント
export default function Articles() {
const articles = useLoaderData<typeof loader>();
return (
<div>
{/* React RouterのFormコンポーネント */}
<Form method="post">
<input type="text" name="title" required />
<button type="submit">投稿</button>
</Form>
<ul>
{articles?.map((article: any) => (
<li key={article.id}>{article.title}</li>
))}
</ul>
</div>
);
}
ポイント:
- サーバーサイド処理にDBアクセスのロジックを閉じ込める
- これにより、従来の
useEffect
内でfetchする手法に比べ、関心の分離が明確になる
これはBaaSの否定か、進化か
anon key
から権限を剥奪する。一見すると、これはBaaSの手軽さを自ら捨て去る愚かな行為に見えるかもしれない。
しかし、これは「BaaSが使えない」のではなく、モダンフロントエンドフレームワークの思想を深く理解し、BaaSをより堅牢なバックエンドインフラとして活用するという、一つの進化形だと私は考えている。
これはBaaS運用からの逃げではない。むしろ、より主体的なBaaSとの付き合い方なのだ。