メインコンテンツへスキップ
データベース・RLS
Supabase
認証・認可
セキュリティ
Next.js
TypeScript
PostgreSQL

Supabase Auth 実装ガイド:認証フローの選定・JWT署名鍵・セッション・MFA を本番品質で設計する

Supabase Authを「サインインできた」から「本番の攻撃と運用に耐える」へ。認証方式の選定基準、token_hashを使う/auth/confirmの正しい実装、共有シークレット(HS256)から非対称鍵(ES256)への無停止移行とgetClaims、セッションの有効期限とリフレッシュトークン再利用間隔、MFAとAALのRLS強制、匿名サインインの安全な使い方、カスタムクレーム、レート制限とリダイレクト許可リストまで、公式ドキュメント準拠の実コードで解説します。

公開日
読了時間
21分
著者
友田 陽大
シェア

Supabase Authは、10行でサインインが動きます。そしてその10行のまま本番に出ると、たいてい3つの穴が空いています

  1. JWTを共有シークレット(HS256)で署名したまま——鍵の失効が難しく、検証のたびにAuthサーバーへ往復している
  2. 権限を user_metadata に置いている——ユーザー自身が書き換えられる場所に、権限を置いている
  3. MFAを「アプリのif文」で強制している——1本の実装漏れで全部抜ける

この記事は、Supabase Authを本番品質にするための実装ガイドです。公式ドキュメント(2026-08-16時点)に忠実に、認証方式の選定から、非対称鍵への無停止移行、セッションの寿命設計、MFAのRLS強制、匿名サインインの安全な使い方、濫用対策までを、そのまま使えるコードで扱います。

この記事が扱わないこと:Next.js App Routerとの結線(@supabase/ssrcreateBrowserClient / createServerClient、CookieのgetAll/setAll、middlewareでのトークン更新)は、Next.js App RouterでSupabase RLSを正しく効かせるで1本まるごと扱っています。本記事はAuth側の設計に集中します。


1. 認証方式の選定:使う人で決まる

Supabase Authが提供する方式は、公式ドキュメントによれば次の通りです。

  • パスワード認証(email + password)
  • パスワードレス(マジックリンク / ワンタイムパスワード)
  • ソーシャルログイン(19プロバイダ)
  • 電話認証(MessageBird / Twilio / Vonage)
  • エンタープライズSSO(SAML 2.0)
  • 多要素認証(TOTP または 電話)
  • カスタムプロバイダ(任意のOAuth2 / OIDC互換IdP)

加えて匿名サインインがあります。選定は「機能比較」ではなく「誰が使うか」で決めます。

想定ユーザー既定の方式理由と注意点
一般消費者(B2C)パスワード + ソーシャル摩擦が最小。ただしパスワードは強度設定と漏洩検知(Pro以上)を必ず有効化
買い切り・低頻度利用マジックリンク / OTPパスワード管理そのものを無くせる。組み込みメールは1時間2通の制限があるためカスタムSMTPが事実上必須
社内システム・BtoBSSO(SAML 2.0)アカウントのライフサイクルをIdPに寄せられる。退職時の即時失効が要件なら唯一の現実解
「まず触ってほしい」プロダクト匿名サインイン → 昇格体験を先に見せられる。ただし濫用対策必須(第7章)
金銭・個人情報を扱う上記 + MFAAALをRLSで強制する(第6章)

メール系エンドポイントの制限は設計に直結します。 公式のレート制限表によれば、組み込みメールプロバイダでは /auth/v1/signup/auth/v1/recover/auth/v1/userプロジェクト全体で1時間あたり2通です。開発中は気づきませんが、公開直後に確実に詰まります。カスタムSMTP(Resend、Amazon SES 等)の設定を、公開前チェックリストに入れてください。


2. ユーザーデータの置き場所:auth スキーマを触らない

サインインの次に必ず来るのが「ユーザーのプロフィールをどこに持つか」です。答えは**public スキーマの自前テーブル**です。公式は理由をこう説明しています。

主キーは変更されないことが保証される。Supabaseが管理するカラム・インデックス・制約その他のデータベースオブジェクトはいつでも変更される可能性がある

つまり auth.users主キーだけを外部キーとして参照してよい、それ以外の列やインデックスに依存してはいけない、ということです。

create table public.profiles (
  id uuid not null references auth.users on delete cascade,
  first_name text,
  last_name text,
  primary key (id)
);

alter table public.profiles enable row level security;

サインアップ時の自動作成はトリガで行います。

create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, first_name, last_name)
  values (
    new.id,
    new.raw_user_meta_data ->> 'first_name',
    new.raw_user_meta_data ->> 'last_name'
  );
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

security definer set search_path = '' はセットで書きます。search_path を空にしないと、検索パス経由で意図しないスキーマの関数が呼ばれ得るためです(詳細はSECURITY DEFINER関数とsearch_path)。

決定的に重要:user_metadata は認可に使えない

サインアップ時の raw_user_meta_data(= user_metadata)は、ユーザー自身が updateUser() で書き換えられます。ここに role: "admin" を置いて認可すると、そのアプリは誰でも管理者になれるアプリです。

権限は次のいずれかに置きます。

  • app_metadata — サーバー側からのみ書ける
  • 専用テーブルpublic.user_roles など)— RLSと組み合わせて管理する

そしてJWTに載せるのは第5章のカスタムアクセストークンフックです。


3. メール認証の正しい実装:token_hash/auth/confirm

マジックリンクとメール確認は、実装を1箇所間違えるとリンクを踏んでもサインインできないという定番の詰まり方をします。正しい形はこうです。

まずメールテンプレートを {{ .TokenHash }} を使う形に変更します。

<h2>Sign in to your account</h2>

<p>Use this link to sign in to your account:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Sign in</a></p>

送信側は signInWithOtp です。

const { data, error } = await supabase.auth.signInWithOtp({
  email: "valid.email@supabase.io",
  options: {
    // 未登録アドレスで勝手にユーザーを作らせたくないなら false
    shouldCreateUser: false,
    emailRedirectTo: "https://example.com/welcome",
  },
});

受け側の /auth/confirmverifyOtp に渡してセッションに交換します。

// app/auth/confirm/route.ts (Next.js App Router)
import { type EmailOtpType } from "@supabase/supabase-js";
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";

export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url);
  const tokenHash = searchParams.get("token_hash");
  const type = searchParams.get("type") as EmailOtpType | null;
  // オープンリダイレクト防止: 外部URLは絶対に受け付けない
  const nextParam = searchParams.get("next") ?? "/";
  const next = nextParam.startsWith("/") && !nextParam.startsWith("//") ? nextParam : "/";

  if (!tokenHash || !type) {
    return NextResponse.redirect(new URL("/auth/error?reason=missing_token", origin));
  }

  const supabase = await createClient();
  const { error } = await supabase.auth.verifyOtp({ token_hash: tokenHash, type });

  if (error) {
    // エラー詳細をURLに載せない(ユーザー列挙・情報漏れの経路になる)
    return NextResponse.redirect(new URL("/auth/error?reason=invalid_token", origin));
  }

  return NextResponse.redirect(new URL(next, origin));
}

3つの実務ポイントがあります。

  1. next パラメータを検証する。 next をそのままリダイレクト先にすると、オープンリダイレクトになります。/ 始まりかつ // 始まりでないことを必ず確認します。
  2. エラー理由を詳細に出さない。 「このメールアドレスは存在しません」はユーザー列挙の入口です。公式も resetPasswordForEmail について「そのメールアドレスのアカウントが存在するかどうかを明かさない」と設計意図を述べています。同じ姿勢を自分のハンドラでも保ちます。
  3. リダイレクトURLの許可リストを正しく書く。

リダイレクト許可リストとワイルドカード

redirectTo に指定するURLは、ダッシュボードの Redirect URLs に登録されている必要があります。ワイルドカードの意味は正確に理解してください。

パターンマッチするもの
http://localhost:3000/*/foo/bar/foo/barマッチしない
http://localhost:3000/**/foo/bar/foo/bar
http://localhost:3000/?1文字のパスのみ

区切り文字は ./ です。* は区切りを跨がず、** は跨ぐ——ここが事故の元です。Vercelのプレビューデプロイを使う場合、公式の推奨は Site URL を本番URLにし、加えて http://localhost:3000/**https://*-<team-or-account-slug>.vercel.app/** を登録する形です。


4. JWT署名鍵:HS256 から ES256 へ、無停止で

ここが本番品質と入門の分水嶺です。

Supabaseの従来方式は、プロジェクト全体で1つの共有シークレット(HS256)を使うものでした。公式はこれを「失効の複雑さとセキュリティリスクのため、本番アプリケーションには推奨しない」と明言しています。

現在サポートされる署名鍵はこうです。

アルゴリズムJWT alg位置づけ
NIST P-256 楕円曲線ES256本番推奨。「楕円曲線はRSAより高速な代替」で署名も短い
RSA 2048RS256広くサポートされるが「楕円曲線より著しく低速」
Ed25519EdDSA近日対応。現時点ではランタイム側の対応が限定的
HMAC 共有シークレットHS256本番非推奨

なぜ非対称鍵にすると速くなるのか

非対称鍵にすると、公開鍵がJWKSエンドポイントで配布されます

GET https://project-id.supabase.co/auth/v1/.well-known/jwks.json

このエンドポイントはSupabaseのエッジで10分キャッシュされます。結果として supabase.auth.getClaims()Authサーバーへの往復なしにローカル検証できるようになります。公式の説明はこうです。

このエンドポイントはしばしばキャッシュされるため、(getUser に比べ)大幅に高速なレスポンスとなる。プロジェクトが対称鍵署名の場合は、ローカル検証ができないため getUser と同様にAuthサーバーへリクエストを送る。

つまり 非対称鍵への移行は、セキュリティ改善であると同時にレイテンシ改善です。サーバーコンポーネントが毎レンダリングで認可判断をするNext.jsでは、効き方が大きい。

3つのメソッドの使い分け

メソッド何をするか使う場所
getClaims()非対称鍵ならJWKSでローカル検証。対称鍵ならサーバー問い合わせサーバー側の認可判断の第一選択
getUser()Authサーバーに問い合わせて検証最新のユーザー情報が必要なとき
getSession()クライアントストレージ由来の値を再検証せずに返すサーバーでの認可判断に使ってはいけない

移行手順(ユーザーを強制サインアウトさせない)

署名鍵は4つの状態を持ちます。

Standby       … 作成済みだが、まだトークン発行には使われない。公開鍵はJWKSに載る
   ↓ rotate
In Use        … 現在トークンを発行している鍵
   ↓ rotate
Previously    … 直前の鍵。既存トークンの検証のためにまだ信頼される
Used
   ↓ revoke
Revoked       … 検証にも使われない

公式のダッシュボード手順はこうです。

  1. JWT signing keys ページで「Migrate JWT secret」— 既存の共有シークレットを新方式に取り込み、同時に非対称のstandby鍵を作成する
  2. アプリが署名鍵に直接依存していないか確認する(josejsonwebtoken で自前検証していないか)
  3. 「Rotate keys」で非対称鍵を現行にする
  4. アクセストークンの有効期限(例:1時間)+バッファ(15分)を待ってから、旧シークレットをrevokeする

事前に必ず確認する2点を公式が警告しています。

レガシーのJWTシークレットにアプリが直接依存していないことを確認すること。すべてのJWTをレガシーシークレットで検証している構成のままローテーションを続けると、それらのコンポーネントが壊れる可能性がある。

Verify JWT 設定を使っているEdge Functionsがある場合、ローテーションを続けるとアプリが壊れる可能性がある。この設定をオフにする必要がある。

一方、ユーザー影響についてはこうです。

期限切れでないアクセストークンは引き続き受理されるため、強制的にサインアウトされるユーザーはいない。

自前で検証している箇所があるなら、先に getClaims() へ置き換えてから移行します。

// Before: 共有シークレットで自前検証(移行の障害になる)
// const payload = jwt.verify(token, process.env.SUPABASE_JWT_SECRET!);

// After: 署名鍵の種類に依存しない
const { data, error } = await supabase.auth.getClaims();
if (error || !data) {
  // 未認証として扱う
}
const userId = data.claims.sub;
const role = data.claims.role;

なお getClaims() は「Supabase Authが発行したJWTでのみ使うためのもの」です。サードパーティIdPのトークンを検証する用途には使いません。


5. セッション設計:寿命を決めるのは自分

公式の定義はこうです。

セッションはユーザーがサインインしたときに作られる。既定では無期限に続き、ユーザーは好きなだけのデバイスで無制限の数のアクティブセッションを持てる。

「既定では無期限」——ここを読み飛ばすと、退職者の端末に残ったセッションが延々生き続けます。

項目既定値補足
アクセストークン有効期限1時間公式は「多くのアプリは既定の1時間を使うべき」。5分未満は非推奨(サーバー負荷、クロックスキュー、長時間リクエスト中のエラー)
リフレッシュトークン期限なし・1回だけ使用可使うたびに新しいペアが発行される(ローテーション)
リフレッシュトークン再利用間隔10秒「既定は10秒で、この値の変更は推奨しない」
セッション時間上限(タイムボックス)未設定Proプラン以上
無操作タイムアウト未設定Proプラン以上
単一セッション強制未設定Proプラン以上

再利用間隔(10秒)が存在する理由

リフレッシュトークンが厳密に1回きりだと、タブを2枚開いているだけで壊れます。両方のタブがほぼ同時に更新を試み、片方が「使用済みトークン」を掴んでサインアウトさせられるからです。10秒の猶予はこの競合を吸収するためのもので、だから公式は変更を推奨しません。

何を設定すべきか

  • アクセストークンは1時間のままにする。短くするとリフレッシュ回数が増え、/auth/v1/token のIPベース制限(1時間あたり1,800リクエスト)に近づきます。
  • 金銭・個人情報を扱うならタイムボックスと無操作タイムアウトを設定する(Pro以上)。「無期限に続く」既定は、監査で必ず指摘されます。
  • サインアウトのスコープを意識する。 全デバイスから落とすのか、そのセッションだけかは要件次第です。

6. MFA と AAL:DBで強制する

多要素認証は、アプリのif文で強制してはいけません。1本の実装漏れで全部抜けるからです。

Supabaseは認証の強度を**AAL(Authenticator Assurance Level)**としてJWTの aal クレームに持ちます。

  • aal1 — 「メール+パスワード、マジックリンク、ワンタイムパスワード、電話認証、ソーシャルログインといった従来型のログイン方法で本人確認された」
  • aal2 — 「TOTPコードやワンタイムパスワードコードといった、少なくとも1つの第二要素で追加的に本人確認された」

クレームが無い場合は aal1 として扱われます。

登録から検証まで

APIは3つです。Enrollment(要素の追加・削除)、Challenge / Verify(要素へのアクセス確認)、List Factors(登録済み要素の表示)。加えて unenroll()listFactors()getAuthenticatorAssuranceLevel() があります。サポートされる要素はTOTP(認証アプリ)と電話です。

RLSで強制する

ここが本題です。公式が示す形はこうです。

-- 全ユーザーにMFAを要求する
create policy "mfa_required"
on public.sensitive_table
as restrictive
to authenticated
using ((select auth.jwt()->>'aal') = 'aal2');
-- 「MFAを登録済みのユーザーにだけ」aal2 を要求する(段階的導入)
create policy "mfa_required_for_enrolled"
on public.sensitive_table
as restrictive
to authenticated
using (
  array[(select auth.jwt()->>'aal')] <@ (
    select case
      when count(id) > 0 then array['aal2']
      else array['aal1', 'aal2']
    end
    from auth.mfa_factors
    where user_id = (select auth.uid()) and status = 'verified'
  )
);

as restrictive が本質です。通常のポリシー(permissive)はORで結合されるため、他のポリシー1本で条件が満たされてしまいます。restrictiveはANDで結合されるため、他のポリシーが何を許可しようとMFA条件は必ず適用されます。公式も「すべてのポリシーは permissive なポリシーを上書きするために as restrictive 句を使うべき」と述べています。

(select auth.jwt()->>'aal')(select ...) で包むのは、行ごとの再評価を避ける性能最適化です(詳細はRLSの性能最適化)。

段階的導入の順序

  1. MFAの登録UIを出す(任意登録)
  2. 上の「登録済みのユーザーにだけ要求する」ポリシーを入れる — 未登録者は影響を受けない
  3. 登録率が十分になったら、管理者ロールなど高権限から必須化する
  4. 最後に全体を = 'aal2' に切り替える

いきなり4から始めると、全ユーザーが締め出されます。


7. 匿名サインイン:体験を先に見せる、ただし守る

「登録前に触ってほしい」プロダクトでは、匿名サインインが効きます。

const { data, error } = await supabase.auth.signInAnonymously();

匿名ユーザーのJWTには is_anonymous クレームが載ります。公式の言葉では「これらのユーザーのJWTには is_anonymous クレームがあり、RLSポリシーで区別に使える」。

RLSで「匿名にできないこと」を宣言する

create policy "Only permanent users can post to the news feed"
on public.news_feed
as restrictive
for insert
to authenticated
with check ((select (auth.jwt()->>'is_anonymous')::boolean) is false);

ここでも as restrictive です。「読めるが書けない」「試せるが公開できない」という境界を、アプリではなくDBに宣言します。

永続アカウントへの昇格

  • メール / 電話updateUser() にアドレスを渡す。検証後にパスワードを設定できる
  • OAuthlinkIdentity() にプロバイダを渡す

アイデンティティのリンクには重要な制約があります。Supabaseは同じメールアドレスを持つアイデンティティを自動的に1ユーザーに紐づけます。ただし「検証されていないメールアドレスのユーザーにアイデンティティを自動リンクするのは安全でない慣行」であるため、メール検証が前提です。アカウント乗っ取り防止の要です。また、アイデンティティを1つ解除するには2つ以上リンクされている必要があります

濫用対策は必須

公式の警告は明確です。

匿名ユーザーはデータベースに保存されるため、悪意ある利用者がエンドポイントを叩いてデータベースサイズを劇的に増やせる。

対策は次の通りです。

  • CAPTCHA を有効化する(公式推奨)
  • 既定のIPベース制限は1時間あたり30リクエスト。ダッシュボードで変更可能
  • 古い匿名ユーザーを定期的に削除する運用を最初から入れる(後から作ると、消してよい行の判別が難しくなります)

8. カスタムクレーム:権限をJWTに載せる

第2章で述べた通り、権限は user_metadata に置けません。JWTに載せる正規の方法がカスタムアクセストークンフックです。

フックはPostgres関数として書き、Authがトークンを発行する直前に呼ばれます。入出力はJSONです。

入力: { "user_id": "...", "claims": { ... }, "authentication_method": "..." }
出力: { "claims": { ... } }

公式が示す例(アクセス制限フック)は、grant / revoke の作法まで含めてこうなっています。

create or replace function public.restrict_application_access(event jsonb)
 returns jsonb
 language plpgsql
as $function$
declare
    authentication_method text;
    email_claim text;
    allowed_emails text[] := array['myemail@company.com', 'example@company.com'];
begin
    email_claim = event->'claims'->>'email';
    authentication_method = event->'authentication_method';
    authentication_method = replace(authentication_method, '"', '');

    if email_claim ilike '%@supabase.io'
       or authentication_method = 'sso/saml'
       or email_claim = any(allowed_emails) then
        return event;
    end if;

    return jsonb_build_object(
        'error', jsonb_build_object(
            'http_code', 403,
            'message', 'Staging access is only allowed to team members. Please use your @company.com account instead'
        )
    );
end;
$function$
;

grant execute on function public.restrict_application_access to supabase_auth_admin;
revoke execute on function public.restrict_application_access from authenticated, anon, public;

最後の2行が最重要です。supabase_auth_admin にだけ実行を許し、authenticated / anon / public からは剥奪する。これを忘れると、フック関数そのものを一般ユーザーが呼べてしまいます。

ロールを載せる場合も同じ骨格です。

create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
  claims jsonb;
  user_role text;
begin
  select role into user_role
  from public.user_roles
  where user_id = (event->>'user_id')::uuid;

  claims := event->'claims';

  if user_role is not null then
    claims := jsonb_set(claims, '{app_metadata,app_role}', to_jsonb(user_role));
  end if;

  return jsonb_set(event, '{claims}', claims);
end;
$$;

grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;

JWT肥大に注意

公式が付けている警告を軽視しないでください。

特にサーバーサイドレンダリングのフレームワークを使っている場合、JWTのサイズが問題になり得る。

JWTはCookieに乗り、すべてのリクエストで往復します。ロール名1つなら問題ありませんが、権限の配列やOAuthプロバイダ由来の大きなクレームを詰め込むと、Cookieサイズの上限に当たり、ヘッダサイズがレイテンシに効きます。JWTには「認可判断に必要な最小限」だけを載せ、詳細はDBから引くのが原則です。

(ロールベースの認可をRLS側でどう表現するかは、RLS × RBAC・カスタムクレームで詳しく扱っています。)


9. 濫用対策:レート制限とパスワード

Supabase Authには既定のレート制限が入っています。設計時に把握しておくべき数字はこれです。

操作パス制限単位既定値変更可否
メール送信/auth/v1/signup, /auth/v1/recover, /auth/v1/userプロジェクト全体1時間2通(組み込みプロバイダ)カスタムSMTPのみ
OTP送信/auth/v1/otpプロジェクト全体1時間30通
OTP / マジックリンク/auth/v1/otpユーザー単位60秒間隔
サインアップ確認/auth/v1/signupユーザー単位60秒間隔
パスワードリセット/auth/v1/recoverユーザー単位60秒間隔
検証リクエスト/auth/v1/verifyIPアドレス1時間360回(バースト30)不可
トークン更新/auth/v1/tokenIPアドレス1時間1,800回(バースト30)不可
MFA challenge / verify/auth/v1/factors/:id/challenge, /verifyIPアドレス1時間15回不可
匿名サインイン/auth/v1/signupIPアドレス1時間30回(バースト30)不可

MFAの1時間15回は、正当な利用でも当たり得る水準です。ユーザーがコードを打ち間違え続けたときのUIを、エラー表示だけで終わらせず「時間をおいて再試行」まで案内してください。

パスワード側の設定はこうです。

  • 最小文字数 — 「8文字未満は非推奨」
  • 必須文字種 — 「数字・小文字・大文字・記号をすべて要求する最強のオプションを使う」
  • 漏洩パスワード保護 — HaveIBeenPwned の Pwned Passwords API で既知の漏洩パスワードを拒否。Proプラン以上

既存ユーザーは新しい要件に関係なく現在のパスワードでサインインできますが、弱いパスワードの場合はサインイン時に WeakPasswordError が返ります。これを握りつぶさず、パスワード更新への導線にするのが良い設計です。


10. 本番前チェックリスト

  • JWT署名鍵を非対称鍵(ES256)に移行した/移行計画がある
  • 自前のJWT検証(jose / jsonwebtoken)を getClaims() に置き換えた
  • Edge Functions の Verify JWT 設定を確認した(鍵ローテーション前に必須)
  • サーバー側の認可判断で getSession() を使っていない
  • 権限を user_metadata に置いていない(app_metadata または専用テーブル)
  • カスタムアクセストークンフックに grant(supabase_auth_admin)と revoke(authenticated/anon/public)がある
  • JWTに載せるクレームが最小限である(Cookieサイズを見た)
  • /auth/confirmnext パラメータを検証している(オープンリダイレクト防止)
  • エラーメッセージがユーザー列挙に使えない
  • リダイレクトURL許可リストの *** の違いを理解して登録した
  • カスタムSMTPを設定した(組み込みは1時間2通)
  • セッションのタイムボックス/無操作タイムアウトを要件に沿って設定した
  • MFAを as restrictive のRLSで強制している(アプリのif文だけに頼らない)
  • 匿名サインインを使うなら、CAPTCHA・RLSでの is_anonymous 制限・古い匿名ユーザーの削除運用がある
  • パスワード強度設定と漏洩パスワード保護(Pro以上)を有効化した
  • public.profilesauth.users主キーのみを参照している

まとめ:認証の強度は「どこで判断しているか」で決まる

Supabase Authを本番品質にする作業は、機能を足すことではありません。判断の場所を正しく置き直すことです。

  • 署名鍵を非対称にすれば、検証はJWKSの公開鍵で局所的に行える
  • 権限を app_metadata とフックに置けば、判断はサーバー側の事実に基づく
  • MFAをRLSに書けば、強制はアプリの経路数に依存しない
  • 匿名の制限をRLSに書けば、境界は実装漏れの影響を受けない

いずれも共通しているのは、「アプリのコードが正しく書かれていること」に依存しない場所へ判断を移すという発想です。認証は入口の話に見えて、その実、システム全体の信頼境界をどこに引くかという設計判断です。ここを最初に正しく置けたプロジェクトは、後から機能が増えても壊れません。

よくある質問

Supabase Auth で最初にやるべき設定は何ですか?
JWT署名鍵を共有シークレット(HS256)から非対称鍵(ES256)へ移行することです。非対称鍵なら公開鍵がJWKSエンドポイントで配布され、supabase.auth.getClaims()がAuthサーバーへの往復なしにローカル検証できます。移行はダッシュボードから standby キーを作り、rotate し、アクセストークンの有効期限を過ぎてから旧キーを revoke する4段階で、ユーザーを強制サインアウトさせずに完了します。ただしEdge FunctionsのVerify JWT設定を使っている場合は事前に確認が必要です。
getSession() / getUser() / getClaims() はどう使い分けますか?
サーバー側の認可判断には getClaims() を第一選択にしてください。非対称鍵ならJWKSの公開鍵でローカル検証するため高速で、公式も「多くの場合キャッシュが効くため大幅に高速」としています。getUser() はAuthサーバーに問い合わせて検証するため確実ですが往復が発生します。getSession() はクライアント側ストレージ由来の値を再検証せずに返すため、サーバーでの認可判断に使ってはいけません。
ユーザーの権限(ロール)はどこに保存すべきですか?
user_metadata には絶対に置かないでください。ユーザー自身が updateUser() で書き換えられるため、そこを見て認可すると権限昇格になります。権限は app_metadata、あるいは専用テーブル(user_roles など)に置き、カスタムアクセストークンフックでJWTのクレームとして載せます。フックはPostgres関数として書き、supabase_auth_admin にだけ実行権限を与え、authenticated / anon / public からは revoke するのが公式の作法です。
MFA を本当に強制できていることをどう保証しますか?
アプリ側のガードだけでは、1本の実装漏れで抜けます。JWTの aal クレームをRLSで検証してください。公式は as restrictive なポリシーで using ((select auth.jwt()->>'aal') = 'aal2') と書く方法を示しています。restrictive にするのは、他の permissive ポリシーで上書きされないようにするためです。これでアプリの経路に関係なく、二要素を通っていないセッションはデータに到達できません。
匿名サインインは使っても安全ですか?
用途を限れば有用ですが、そのままでは濫用経路になります。公式は「匿名ユーザーはデータベースに保存されるため、悪意ある利用者がエンドポイントを叩いてデータベースサイズを劇的に増やせる」と警告し、CAPTCHA の有効化を推奨しています。既定のIPベースのレート制限は1時間あたり30リクエストです。あわせてRLS側で、投稿や課金に関わるテーブルには as restrictive で is_anonymous が false であることを要求してください。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

Supabase を土台にしたアプリを、設計から本番運用まで承ります

Realtime の設計(Broadcast / Presence / Postgres Changes の使い分けと再接続時の整合)、Auth の認証フロー・JWT署名鍵・MFA、Edge Functions での冪等な Webhook 受信、Storage のアップロード経路とコスト設計まで。認可を RLS で DB に寄せた設計を、モバイル+Web の実プロダクトで単独構築した知見で、落ちない・追える・変更しやすいアプリを実装します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい