---
title: "Supabase raw_user_meta_data：登録だけでOwnerに"
description: "raw_user_meta_dataからrole_idを読むトリガーのせいで、誰でもOwnerになれました。経緯と招待テーブルでの修正を紹介します。"
url: "https://madsalt.tech/ja/notes/supabase-raw-user-meta-data-role/"
author: "Denys Zaichenko"
language: "ja"
published: 2026-10-18
---

# Supabase raw_user_meta_data：登録だけでOwnerに

TL;DR

`raw_user_meta_data`には、ブラウザが`supabase.auth.signUp()`に渡した内容がそのまま入ります。ある本番アプリでは`handle_new_user`トリガーが新規ユーザーのロールをここから読んでいたため、誰でもOwnerロールのIDでサインアップして、アカウントを所有できました。修正は、ロールをサーバーだけが書き込めるテーブルから取得し、そのテーブルを招待の送信前に埋めることです。

これは[監査法人の工数管理アプリ](https://madsalt.tech/ja/work/audit-firm-time-tracking/)での話です。アクセスルールがすべてデータベースにあるSupabaseアプリで、バグは新規ユーザーごとにプロフィールを作るトリガーにありました。

## トリガーは何をしていたのか？

簡略化すると、次のようなものでした。

```sql
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
declare
  _role_id uuid := (new.raw_user_meta_data ->> 'role_id')::uuid;
begin
  insert into public.profiles (id, full_name, role_id)
  values (new.id, new.raw_user_meta_data ->> 'full_name', _role_id);
  return new;
end;
$$;
```

招待フローが正しい`role_id`をそこに入れていたので、通常の使い方では動いていました。しかし`raw_user_meta_data`はサインアップの`options.data`であり、何を入れるかを決めるのはブラウザです。

```js
await supabase.auth.signUp({
  email: 'me@example.com',
  password: '...',
  options: { data: { role_id: '<owner role uuid>' } },
})
```

権限チェックは、他の何かを見る前にOwnerなら真を返していました。そのため、このフィールド1つでアカウントを完全に掌握できました。Supabase自身の[Usersガイド](https://supabase.com/docs/guides/auth/users)にも、ユーザーメタデータはRLSポリシーや認可ロジックに使ってはならないと書かれています。ユーザーが何の検証もなしに編集できるからです。

![新規ユーザーのロールの出どころ。修正前と修正後のhandle_new_userトリガー。修正前：サインアップした誰でも：signUp options.data → raw_user_meta_data → handle_new_user → Ownerロール、修正後：招待された人だけ：招待関数 → pending_invites → handle_new_user → 招待されたロール。](https://madsalt.tech/charts/ja/supabase-raw-user-meta-data-role.svg)

*本番のSupabaseアプリのサインアップトリガーより、2026年6月。*

## 何が修正になったのか？

ロールは、クライアントが触れないプライベートなテーブルから取得するようにしました。

```sql
create table private.pending_invites (
  email text primary key,
  role_id uuid,
  invited_by uuid,
  created_at timestamptz default now()
);
revoke all on table private.pending_invites from anon, authenticated;
```

招待の関数はサーバー上で動き、招待する側の権限を確認します。先に保留中の招待を書き込み、そのあとで招待リンクを作成します。トリガーはメールアドレスでロールを検索し、その行を削除します。名前は引き続きメタデータから取ります。表示用のデータなので問題ありません。

順序が重要です。招待するとユーザーはすぐに作成されます。Supabaseの[Usersガイド](https://supabase.com/docs/guides/auth/users)には、新しいメールアドレスを招待すると未確認のユーザーが作成されると書かれています。トリガーはその時点で発火するため、保留中の行はすでに存在していなければなりません。

## 自分のアプリをどう確認する？

`handle_new_user`関数、または`auth.users`上の任意のトリガーを開き、`raw_user_meta_data`を検索してください。そこから名前やアバターを取るのは問題ありません。ロール、会社、プラン、テナントなど、アクセスを与えるものは、テーブルか、ユーザーが変更できない`raw_app_meta_data`から取る必要があります。

そのうえで試してください。APIで`options.data`に`role_id`または`role: 'admin'`を入れてサインアップします。新しいアカウントには、何も余分に付与されないはずです。

この種の確認は、私の[RLSテスト方法](https://madsalt.tech/ja/blog/how-to-test-supabase-rls/)の最初のステップです。マルチテナントのアプリでは、[2つの組織に属するユーザーでRLSが見逃すもの](https://madsalt.tech/ja/notes/supabase-rls-user-in-two-organizations/)もあわせて読んでください。すべてのSupabaseアプリで私が実行しているその他の確認は、[Lovableアプリのセキュリティチェックリスト](https://madsalt.tech/ja/blog/lovable-app-security-checklist/)にあります。ダッシュボードで私が最初に見るのは、[Data APIが公開しているスキーマ](https://madsalt.tech/ja/notes/supabase-exposed-schemas/)です。[Supabase セキュリティチェック](https://madsalt.tech/ja/tools/supabase-security-check/)は、ロール、grant、ポリシー、認証設定を46件のOWASP Top 10:2025ルールに照らして調べますが、トリガーがサインアップのメタデータから何をコピーするかは対象外なので、この確認は手作業のままです。
