Clerkで始める次世代認証・ユーザー管理


Clerkとは

Clerk は、モダンなWebアプリケーション向けの認証・ユーザー管理プラットフォームです。従来の認証ライブラリとは異なり、UI コンポーネントから認証フロー、セキュリティまでをトータルでサポートする、完全なソリューションを提供します。

Clerkの主な特徴

  • 完全なUIコンポーネント: サインイン、サインアップ、ユーザープロフィールなど、すぐに使える美しいUIコンポーネント
  • 多様な認証方法: Email/パスワード、ソーシャルログイン、パスワードレス、多要素認証(MFA)
  • 開発者体験の最適化: TypeScript完全対応、フレームワーク別SDK、詳細なドキュメント
  • エンタープライズグレードのセキュリティ: SAML SSO、組織管理、高度な権限制御
  • 無料プランの充実: 月間10,000アクティブユーザーまで無料

なぜClerkを選ぶのか

従来の認証実装の課題

従来、認証機能を実装するには以下のような課題がありました:

  1. 複雑な実装: パスワードのハッシュ化、トークン管理、セッション管理など、セキュリティを考慮した実装は複雑
  2. UI/UXの構築: サインイン/サインアップフォーム、パスワードリセット、メール認証など、多くのUIを構築する必要がある
  3. 保守の負担: セキュリティアップデート、GDPR対応、監査ログなど、継続的な保守が必要

Clerkが解決すること

Clerk を使うことで、これらの課題を解決できます:

// たった数行で完全な認証機能を実装
import { SignIn } from "@clerk/nextjs";

export default function SignInPage() {
  return <SignIn />;
}

セットアップと基本実装

1. プロジェクト作成とインストール

Next.js プロジェクトでの例:

# Next.js プロジェクト作成
npx create-next-app@latest my-clerk-app
cd my-clerk-app

# Clerk SDK インストール
npm install @clerk/nextjs

2. 環境変数の設定

Clerk ダッシュボード(https://clerk.com)でアプリケーションを作成し、APIキーを取得します。

.env.local:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxx
CLERK_SECRET_KEY=sk_test_xxxxx

# オプション: カスタムURLの設定
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding

3. ClerkProviderのセットアップ

App Router(Next.js 13+)の場合:

app/layout.tsx:

import { ClerkProvider } from '@clerk/nextjs';
import { jaJP } from '@clerk/localizations';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <ClerkProvider localization={jaJP}>
      <html lang="ja">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  );
}

Pages Router(Next.js 12以前)の場合:

pages/_app.tsx:

import { ClerkProvider } from '@clerk/nextjs';
import type { AppProps } from 'next/app';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ClerkProvider {...pageProps}>
      <Component {...pageProps} />
    </ClerkProvider>
  );
}

export default MyApp;

4. サインイン/サインアップページの作成

app/sign-in/[[...sign-in]]/page.tsx:

import { SignIn } from "@clerk/nextjs";

export default function SignInPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <SignIn
        appearance={{
          elements: {
            rootBox: "mx-auto",
            card: "shadow-lg",
          },
        }}
      />
    </div>
  );
}

app/sign-up/[[...sign-up]]/page.tsx:

import { SignUp } from "@clerk/nextjs";

export default function SignUpPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <SignUp
        appearance={{
          elements: {
            rootBox: "mx-auto",
            card: "shadow-lg",
          },
        }}
      />
    </div>
  );
}

認証状態の取得と活用

クライアントコンポーネントでの認証状態取得

'use client';

import { useUser, useAuth } from '@clerk/nextjs';

export function UserProfile() {
  const { user, isLoaded, isSignedIn } = useUser();
  const { signOut } = useAuth();

  if (!isLoaded) {
    return <div>読み込み中...</div>;
  }

  if (!isSignedIn) {
    return <div>ログインしてください</div>;
  }

  return (
    <div>
      <h2>ようこそ、{user.firstName}さん</h2>
      <p>メール: {user.primaryEmailAddress?.emailAddress}</p>
      <img
        src={user.imageUrl}
        alt={user.fullName || 'User'}
        className="w-12 h-12 rounded-full"
      />
      <button onClick={() => signOut()}>ログアウト</button>
    </div>
  );
}

サーバーコンポーネントでの認証状態取得

import { currentUser, auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const user = await currentUser();

  if (!user) {
    redirect('/sign-in');
  }

  return (
    <div>
      <h1>ダッシュボード</h1>
      <p>ようこそ、{user.firstName}さん</p>
    </div>
  );
}

ミドルウェアでの認証保護

middleware.ts:

import { authMiddleware } from "@clerk/nextjs";

export default authMiddleware({
  // 公開ページ(認証不要)
  publicRoutes: ["/", "/api/webhook"],

  // 無視するルート(Clerk が処理しない)
  ignoredRoutes: ["/api/public"],
});

export const config = {
  matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};

より細かい制御が必要な場合:

import { authMiddleware } from "@clerk/nextjs";
import { NextResponse } from "next/server";

export default authMiddleware({
  publicRoutes: ["/"],

  async afterAuth(auth, req) {
    // 未認証ユーザーを保護されたルートからリダイレクト
    if (!auth.userId && !auth.isPublicRoute) {
      const signInUrl = new URL('/sign-in', req.url);
      signInUrl.searchParams.set('redirect_url', req.url);
      return NextResponse.redirect(signInUrl);
    }

    // 管理者のみアクセス可能なルート
    if (req.nextUrl.pathname.startsWith('/admin')) {
      const user = await auth.user;
      const isAdmin = user?.publicMetadata?.role === 'admin';

      if (!isAdmin) {
        return NextResponse.redirect(new URL('/forbidden', req.url));
      }
    }

    return NextResponse.next();
  },
});

export const config = {
  matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};

API ルートでの認証

App Router での API ルート認証

app/api/protected/route.ts:

import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

export async function GET() {
  const { userId } = auth();

  if (!userId) {
    return NextResponse.json(
      { error: '認証が必要です' },
      { status: 401 }
    );
  }

  // ユーザー情報を使った処理
  const data = await fetchUserData(userId);

  return NextResponse.json({ data });
}

async function fetchUserData(userId: string) {
  // データベースからユーザーデータを取得
  return { userId, message: 'Protected data' };
}

トークンの検証

外部APIとの連携時にトークンを検証:

import { auth } from '@clerk/nextjs/server';

export async function POST(req: Request) {
  const { userId, getToken } = auth();

  if (!userId) {
    return new Response('Unauthorized', { status: 401 });
  }

  // カスタムクレームを含むJWTトークンを取得
  const token = await getToken({ template: 'custom-template' });

  // 外部APIにトークンを渡す
  const response = await fetch('https://api.example.com/data', {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });

  const data = await response.json();
  return Response.json(data);
}

ユーザーメタデータとカスタマイズ

ユーザーメタデータの種類

Clerk では3種類のメタデータを扱えます:

  1. publicMetadata: クライアント側で読み取り可能、サーバー側で書き込み可能
  2. privateMetadata: サーバー側でのみアクセス可能
  3. unsafeMetadata: クライアント側で読み書き可能(注意して使用)

メタデータの更新

import { clerkClient } from '@clerk/nextjs/server';

export async function updateUserRole(userId: string, role: string) {
  await clerkClient.users.updateUser(userId, {
    publicMetadata: {
      role: role,
      department: 'engineering',
    },
  });
}

export async function updatePrivateData(userId: string) {
  await clerkClient.users.updateUser(userId, {
    privateMetadata: {
      stripeCustomerId: 'cus_xxxxx',
      subscription: 'pro',
    },
  });
}

カスタムフィールドの追加

Clerk ダッシュボードでカスタムフィールドを定義した後、サインアップフォームで使用:

'use client';

import { useSignUp } from '@clerk/nextjs';
import { useState } from 'react';

export function CustomSignUpForm() {
  const { signUp, setActive } = useSignUp();
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    emailAddress: '',
    password: '',
    company: '', // カスタムフィールド
  });

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    try {
      const result = await signUp?.create({
        firstName: formData.firstName,
        lastName: formData.lastName,
        emailAddress: formData.emailAddress,
        password: formData.password,
        unsafeMetadata: {
          company: formData.company,
        },
      });

      if (result?.status === 'complete') {
        await setActive({ session: result.createdSessionId });
      }
    } catch (error) {
      console.error('サインアップエラー:', error);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="名"
        value={formData.firstName}
        onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
      />
      <input
        type="text"
        placeholder="姓"
        value={formData.lastName}
        onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
      />
      <input
        type="email"
        placeholder="メールアドレス"
        value={formData.emailAddress}
        onChange={(e) => setFormData({ ...formData, emailAddress: e.target.value })}
      />
      <input
        type="password"
        placeholder="パスワード"
        value={formData.password}
        onChange={(e) => setFormData({ ...formData, password: e.target.value })}
      />
      <input
        type="text"
        placeholder="会社名"
        value={formData.company}
        onChange={(e) => setFormData({ ...formData, company: e.target.value })}
      />
      <button type="submit">登録</button>
    </form>
  );
}

組織機能(Organizations)

組織の作成と管理

Clerk の組織機能を使うと、チーム/組織単位でのアクセス制御が可能になります:

'use client';

import { useOrganization, useOrganizationList } from '@clerk/nextjs';

export function OrganizationManager() {
  const { organization, membership } = useOrganization();
  const { createOrganization, setActive } = useOrganizationList();

  async function handleCreateOrg() {
    try {
      const org = await createOrganization?.({ name: '新しい組織' });
      await setActive?.({ organization: org?.id });
    } catch (error) {
      console.error('組織作成エラー:', error);
    }
  }

  if (!organization) {
    return (
      <div>
        <p>組織に所属していません</p>
        <button onClick={handleCreateOrg}>組織を作成</button>
      </div>
    );
  }

  return (
    <div>
      <h2>{organization.name}</h2>
      <p>あなたの役割: {membership?.role}</p>
      <p>メンバー数: {organization.membersCount}</p>
    </div>
  );
}

組織メンバーの招待

import { clerkClient } from '@clerk/nextjs/server';

export async function inviteMember(
  organizationId: string,
  emailAddress: string,
  role: 'admin' | 'member'
) {
  const invitation = await clerkClient.organizations.createOrganizationInvitation({
    organizationId,
    emailAddress,
    role,
  });

  return invitation;
}

組織スイッチャー

import { OrganizationSwitcher } from '@clerk/nextjs';

export function Header() {
  return (
    <header>
      <OrganizationSwitcher
        appearance={{
          elements: {
            rootBox: "flex items-center",
          },
        }}
      />
    </header>
  );
}

Webhookとイベント処理

Webhookのセットアップ

Clerk からのイベント通知を受け取るWebhookエンドポイントを作成:

app/api/webhooks/clerk/route.ts:

import { Webhook } from 'svix';
import { headers } from 'next/headers';
import { WebhookEvent } from '@clerk/nextjs/server';

export async function POST(req: Request) {
  const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

  if (!WEBHOOK_SECRET) {
    throw new Error('WEBHOOK_SECRET が設定されていません');
  }

  // ヘッダーを取得
  const headerPayload = headers();
  const svix_id = headerPayload.get('svix-id');
  const svix_timestamp = headerPayload.get('svix-timestamp');
  const svix_signature = headerPayload.get('svix-signature');

  if (!svix_id || !svix_timestamp || !svix_signature) {
    return new Response('Error: Missing svix headers', { status: 400 });
  }

  const payload = await req.json();
  const body = JSON.stringify(payload);

  // Webhookの検証
  const wh = new Webhook(WEBHOOK_SECRET);
  let evt: WebhookEvent;

  try {
    evt = wh.verify(body, {
      'svix-id': svix_id,
      'svix-timestamp': svix_timestamp,
      'svix-signature': svix_signature,
    }) as WebhookEvent;
  } catch (err) {
    console.error('Webhook検証エラー:', err);
    return new Response('Error: Verification failed', { status: 400 });
  }

  // イベントタイプに応じた処理
  const eventType = evt.type;

  switch (eventType) {
    case 'user.created':
      await handleUserCreated(evt.data);
      break;
    case 'user.updated':
      await handleUserUpdated(evt.data);
      break;
    case 'user.deleted':
      await handleUserDeleted(evt.data);
      break;
    case 'organization.created':
      await handleOrganizationCreated(evt.data);
      break;
    default:
      console.log(`未処理のイベント: ${eventType}`);
  }

  return new Response('Webhook処理完了', { status: 200 });
}

async function handleUserCreated(data: any) {
  // データベースにユーザーを作成
  console.log('ユーザー作成:', data.id);
  // await db.user.create({ ... });
}

async function handleUserUpdated(data: any) {
  // データベースのユーザーを更新
  console.log('ユーザー更新:', data.id);
  // await db.user.update({ ... });
}

async function handleUserDeleted(data: any) {
  // データベースからユーザーを削除
  console.log('ユーザー削除:', data.id);
  // await db.user.delete({ ... });
}

async function handleOrganizationCreated(data: any) {
  // データベースに組織を作成
  console.log('組織作成:', data.id);
  // await db.organization.create({ ... });
}

まとめ

Clerk を使うことで、認証・ユーザー管理の実装を大幅に簡素化できます。主なメリット:

  • 開発速度の向上: すぐに使えるUIコンポーネントとシンプルなAPI
  • セキュリティの強化: エンタープライズグレードのセキュリティ機能
  • 柔軟なカスタマイズ: メタデータ、Webhook、カスタムUIでニーズに対応
  • スケーラビリティ: 組織機能で大規模なアプリケーションにも対応

Next.js、React、Remix、Vue、その他多くのフレームワークで利用可能なので、まずは公式ドキュメントを参照して、プロジェクトに導入してみてください。