0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

vibe coding+supabaseで匿名キーからのDBアクセスを無効にした話

Last updated at Posted at 2025-06-20

結論

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。これらのサーバーサイドで実行される領域を活用するのだ。

アーキテクチャはこうだ。

  1. クライアント(ブラウザ)からのリクエストは、まずアプリケーションサーバー(Next.jsなど)に到達する。
  2. Server Componentsやloader関数といった「サーバーサイドの領域」で、service_role keyを使い、安全にSupabaseへアクセスしてデータを取得する。
  3. 取得したデータをpropsやloader dataとして、HTMLと一緒にクライアントに送信する。

DB操作のロジックはすべて、我々の管理下にあるサーバーサイドに閉じ込めることができるのだ。

実装例

具体的なコードを見てみよう。

Case 1: Next.js (App Router) のServer Componentsを使う

Next.jsのApp Routerでは、コンポーネントはデフォルトでServer Componentsとなる。ここで直接DBにアクセスする。

データの書き込み (Server Action & Form)

  1. バリデーションと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')
}
  1. フォームコンポーネント (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との付き合い方なのだ。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?