はじめに
認証に Clerk、データベースに Supabase を使用する構成は非常に強力ですが、「Clerkで登録したユーザー情報をSupabaseのテーブルに同期したい」「SupabaseのRLS(行レベルセキュリティ)をClerkのユーザーIDで制御したい」という場面で設定が複雑になりがちです。
特に、ソーシャルログインは動くのに 「メールアドレス登録だとなぜか同期されない」 という問題や、Clerkの JWTテンプレート設定 でのハマりどころを解決する手順をまとめました。
構成図
- Clerk: ユーザー認証(サインアップ・サインイン)を担当
- Clerk JWT Template: Supabase用のカスタムトークンを発行
- Supabase: データベース(RLSで権限管理)
- Next.js (Client Component): 初回ログイン時にユーザー情報をSupabaseへ自動同期(Upsert)
ステップ1:Supabase側の準備(SQL実行)
まずはSupabase側でユーザー情報を保存するテーブルと、ClerkのJWTからユーザーIDを抽出するための関数を作成します。Supabase Springfield SQL Editorで実行してください。
-- ユーザー情報を保存するテーブル
CREATE TABLE public.users (
id TEXT NOT NULL PRIMARY KEY, -- Clerk User ID (user_...)
email TEXT NOT NULL,
display_name TEXT,
provider TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS 有効化
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Clerk JWT から user_id (sub) を取得するためのヘルパー関数
CREATE OR REPLACE FUNCTION public.requesting_user_id()
RETURNS TEXT
LANGUAGE sql STABLE
AS $$
SELECT NULLIF(
current_setting('request.jwt.claims', true)::json->>'sub',
''
);
$$;
-- 自分のデータのみ参照・更新・削除できるポリシー
CREATE POLICY "Users can view and edit their own data"
ON public.users
FOR ALL
USING (id = public.requesting_user_id())
WITH CHECK (id = public.requesting_user_id());
-- 操作権限を付与
GRANT ALL ON TABLE public.users TO authenticated, service_role;
ステップ2:Clerk側でSupabase連携用JWTテンプレートを作成
ここが最重要ポイントです。ClerkのトークンをSupabaseが「正規のログイン」として認めるための設定を行います。
- Clerk Dashboard の [Configure] > [JWT Templates] を開く。
- [New Template] ボタンを押し、一覧から [Supabase] を選択。
- 名前(Name)を
supabaseに設定。 - [Custom signing key] のトグルを ON にする。
-
Signing key に、Supabase管理画面の
Project Settings > JWT Keys > Legacy JWT SecretにあるLegacy JWT secretを貼り付ける。 - 保存(Save)をクリック。
ステップ3:Next.jsでの同期用コンポーネント実装
ユーザーがログインした際、Clerkの最新情報をSupabaseの public.users テーブルに同期(Upsert)するクライアントコンポーネントを作成します。
'use client';
import { useEffect } from 'react';
import { useUser, useSession } from '@clerk/nextjs';
import { createBrowserClient } from '@supabase/ssr';
export default function ClerkSupabaseSync() {
const { user } = useUser();
const { session } = useSession();
useEffect(() => {
// ユーザーとセッションが揃うまで待機
if (!user || !session) return;
const syncUser = async () => {
try {
// 先ほどClerkで作った 'supabase' テンプレートを指定してトークンを取得
const token = await session.getToken({ template: 'supabase' });
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
global: {
headers: {
Authorization: `Bearer ${token}`,
},
},
}
);
// メールアドレス情報の抽出(フォールバック付き)
const email = user.primaryEmailAddress?.emailAddress || user.emailAddresses[0]?.emailAddress;
if (!email) return;
// public.users テーブルへUpsertを実行
const { error } = await supabase.from('users').upsert({
id: user.id,
email: email,
display_name: user.fullName || user.username || email.split('@')[0],
provider: user.externalAccounts.length > 0 ? user.externalAccounts[0].provider : 'email',
updated_at: new Date().toISOString(),
}, { onConflict: 'id' });
if (error) console.error('❌ Sync Error:', error.message);
else console.log('✅ Profile synced successfully');
} catch (err) {
console.error('❌ Unexpected Error during sync:', err);
}
};
syncUser();
}, [user, session]);
return null;
}
このコンポーネントを layout.tsx の ClerkProvider の直下などに配置すれば、ログイン後に自動で同期が処理されます。
よくあるハマりどころと解決策
1. You can't use the reserved claim: sub エラー
ClerkのJWTテンプレート設定で sub クレームを手動で追加しようとすると発生します。Clerkはデフォルトで sub にユーザーIDを入れてくれる仕様なので、ClaimsのJSONから sub の行を削除すれば解決します。
2. メールアドレス登録だと同期されない(Socialは動くのに)
ソーシャルログインはメールアドレスが検証済み状態で渡されるのに対し、メール登録は検証タイミングによって primaryEmailAddress が一瞬 null に見える場合があります。
実装コードのように user.emailAddresses[0] から取得を試みるなどのフォールバックを入れると、登録直後の同期が安定します。
3. JWT Secret の場所を間違える
Supabaseには複数のAPIキーがありますが、Clerk側の設定で必要なのは anon key ではなく、設定画面の奥にある JWT Secret です。これを間違えると、Supabase側でトークンをデコードできずRLSで弾かれます。
おわりに
ClerkとSupabaseを適切に連携させることで、安全な権限管理(RLS)と、使いやすい認証UIを両立させることができます。この記事が認証周りの実装で悩んでいる方の助けになれば幸いです。