12k
All articles

クライアントサイド vs サーバーサイドの認可:両方が必要な理由

ReactとNext.jsのクライアント側とサーバー側の認可: 権限はサーバーで強制し、クライアントはUXに使い、403のずれを防ぐ。

OpenReplay Team
OpenReplay Team
クライアントサイド vs サーバーサイドの認可:両方が必要な理由

クライアントサイドの認可とは、ブラウザ上で実行されてユーザーが見るものを制御するアクセスロジックであり、サーバーサイドの認可とは、サーバー上で実行されて実際に何が起こるかを決定するアクセスロジックです。UIによるゲーティングがいくら行われていても、サーバーが受け入れる内容は変わりません。これらは異なる2つのトラストバウンダリにおける、異なる2つの役割です。どちらか一方を省略すると、特定の予測可能な障害が生じます。クライアントのみのチェックはDevToolsを開けば誰でも回避でき、サーバーのみのチェックでは、ユーザーが理由もわからないまま403を返すボタンをクリックし続けることになります。

ロールチェックが if (user.role === 'admin') ブロックに記述されたReactまたはNext.jsアプリをリリースしたことがあるなら、すでにクライアントサイドの認可を実装しています。ただし、実際のセキュリティバウンダリがどこにあるかについては、まだ明確でないかもしれません。本記事では、そのバウンダリを正確に定義します。クライアントが信頼できない理由、サーバーのみが重要なゲートキーパーである理由、クライアントサイドのチェックが本来何のためにあるか、そして両方のレイヤーを単一のパーミッション識別子を使って同期させる方法について解説します。

重要なポイント

  • クライアントは信頼できない環境です。ユーザーはDevToolsを開き、自分のロールを保持している変数を書き換え、条件付きレンダリングがどう反応するかを確認できます。そのため、認可の決定はサーバーサイド、ゲートウェイ、またはサーバーレス関数で強制される必要があります(OWASP Authorization Cheat Sheet)。
  • クライアントサイドの認可はUXのためであり、セキュリティのためではありません。管理者ボタンを非表示にし、ルートをゲーティングし、該当するメニュー項目のみを表示することで、ユーザーが行き詰まることを防ぎます。
  • フロントエンドのパーミッション状態は、サーバーが許可する内容のキャッシュです。両者がずれると、フィッシングに悪用されうるワークフロー(サーバーが403を返すボタンをUIが表示する)、または隠れた機能(サーバーが許可しているのにUIが表示しないアクション)が生じます。
  • 両方のレイヤーで role === 'admin' のようなロール名の比較ではなく、tasks:delete のようなパーミッション識別子を使用してください。パーミッション名はロール構造の変更に影響されず、サーバーが実際に強制する単位と一致します。
  • 401は未認証を意味し、ログインページへリダイレクトすべきです。403は認証済みだが認可されていないことを意味し、パーミッション拒否の状態を表示すべきです(RFC 9110 §15.5.2§15.5.4)。

クライアントは信頼できない環境

JavaScriptのすべてのロールチェックは、ユーザーがコントロールする環境で実行されます。ユーザーはDevToolsを開き、自分のロールを保持している変数を見つけ、'admin' に設定して、条件付きレンダリングがどう反応するかを確認できます。変わらないのは、次のリクエストに対するサーバーのレスポンスだけです。これが、クライアントサイドの認可だけでは不十分である根本的な事実です。ブラウザはあなたのコードを実行しますが、ユーザーはブラウザを所有しており、ページロードから次のネットワークリクエストまでの間に何でも書き換えることができます。

OWASP Authorization Cheat Sheet は実用的なルールを明確に述べています。クライアントサイドのアクセス制御チェックはUXを向上させる場合がありますが、クライアントサイドのロジックは容易に回避できるため、認可の決定はサーバーサイド、ゲートウェイ、またはサーバーレス関数で強制される必要があります。この概念的な枠組みは、モダンなSPAが登場するよりずっと前から存在しています。Static Apps guide on authentication では、「エンドユーザーは事前の許可なしにクライアントサイドで任意のコードを実行できる」と表現されています。

30秒でできるDevToolsによる回避

クライアントのみの認可が失敗する理由を最もわかりやすく示すのは、自分でそれを回避してみることです。これは、コンポーネントのstateにロールを保存し、サーバーで検証されたトークンからリクエストごとに導出するのではなく、ロールをstateに保持しているReactアプリに対して、DevToolsを使って任意のブラウザで再現できます。

  1. 一般ユーザー(非管理者)としてログインします。管理者パネルは非表示です。コンポーネントは {user.role === 'admin' && <AdminPanel />} をレンダリングしており、user.role'user' です。
  2. DevToolsを開き、user を保持しているコンポーネントのstateを見つけます。React DevTools を使えば、フックのstateを直接検査・編集できます。それがなくても、ユーザーオブジェクトをミュータブルな参照として公開しているコードパスがあれば機能します。
  3. role'admin' に設定します。Reactが再レンダリングされ、管理者パネルがDOMに表示されます。
  4. パネルに表示された削除ボタンをクリックします。リクエストが送信されます。
  5. サーバーが認可を強制している場合、サーバーはミュータブルなクライアントのstateではなく、トークンからIDを読み取り、403 Forbidden を返します。データは何も変更されません。

攻撃はステップ3(UIが変化する)で成功し、ステップ5(サーバーがリクエストを拒否する)で失敗します。ただし、これはサーバーサイドのチェックが存在する場合に限ります。サーバーサイドのチェックがなければ、ステップ4で実際のデータが変更されます。この回避が機能するのは、ロールがミュータブルなクライアントのstateに存在しているからです。サーバーがすべてのリクエストで検証済みトークンから認可を再導出している場合には機能しません。

サーバーのみが重要なゲートキーパー

データを変更したり保護されたリソースを公開したりする認可の決定は、サーバーで行われ強制される必要があります。なぜなら、サーバーはリクエストの参加者の中でユーザーが書き換えられない唯一の存在だからです。バックエンドは究極のゲートキーパーです。フロントエンドが何をレンダリングし、何を非表示にし、何を無効化しても、サーバーによるリクエストの評価が最終的な決定となります。フロントエンドのコントロールは、バックエンドの強制の代替にはなりません。

以下は、Next.jsのRoute Handlerにおける単一パーミッション tasks:delete のサーバーサイド強制の例です。パーミッションはクライアントが送信するボディからではなく、認証済みセッションから読み取られます。

// app/api/tasks/[id]/route.ts — Next.js App Router
import { NextRequest, NextResponse } from 'next/server';
import { getSessionPermissions } from '@/lib/auth';
import { deleteTask } from '@/lib/tasks';

export async function DELETE(
  req: NextRequest,
  { params }: { params: { id: string } },
) {
  const permissions = await getSessionPermissions(req); // 検証済みトークン/セッションから導出

  if (!permissions.includes('tasks:delete')) {
    return NextResponse.json(
      { error: 'forbidden', permission: 'tasks:delete' },
      { status: 403 },
    );
  }

  await deleteTask(params.id);
  return new NextResponse(null, { status: 204 });
}

403のレスポンスボディには具体的なパーミッション名が含まれています。この詳細は後で重要になります。クライアントがパーミッション拒否を他の障害と区別し、汎用的なトーストではなく適切なメッセージを表示できるようになるからです。403 Forbidden ステータスは RFC 9110 §15.5.4 に従った正しいセマンティクスの選択であり、サーバーがリクエストを理解したが認可を拒否したことを定義しています。

クライアントサイドの認可はインターフェースのためであり、ゲートのためではない

クライアントサイドの認可はインターフェースを形成するために存在します。非管理者から管理者ボタンを非表示にし、ユーザーが403に行き着かないようにルートをゲーティングし、該当するメニュー項目のみを表示します。実際に完了できるアクションにユーザーを誘導することでエクスペリエンスを向上させますが、何かをセキュアにすることはできませんし、そもそもその目的ではありません。フロントエンドは展示ケースであり、金庫ではないと考えてください。到達可能なものや提供されているものを整理しますが、ロックはサーバー側にあります。

標準的な実装では、コンテキストからパーミッションを読み取り、パーミッション識別子に基づいて条件付きレンダリングを行います。

// components/TaskActions.tsx
'use client';
import { usePermissions } from '@/hooks/usePermissions';

export function TaskActions({ taskId }: { taskId: string }) {
  const { can } = usePermissions();

  return (
    <div className="task-actions">
      {can('tasks:delete') && (
        <button onClick={() => deleteTask(taskId)}>Delete</button>
      )}
    </div>
  );
}

識別子に注目してください。role === 'admin' ではなく tasks:delete です。user.role === 'admin' をチェックすることはUIをロールの分類体系に結びつけますが、can('tasks:delete') をチェックすることはサーバーが実際に強制するアクションに結びつけます。サーバーはどのロールがそれを付与したかを気にしません。後で adminadminbilling-admin に分割しても、パーミッション名によるチェックはそのまま機能します。OWASP Authorization Cheat Sheet は、まさにこの理由からロールとパーミッションを分離することを推奨しています。RBACはフロントエンドアプリケーションにおける一般的なアクセス制御モデルです。RBAC/ABAC/ACL/PBACの選択に関する詳細は、LogRocketのアクセス制御モデルの比較が参考になります。

実装上の注意点として、ロールでゲーティングされたUIのセッションリプレイでは、クライアントでフェッチされたパーミッションチェックが解決されて非表示になる前に、未認可ユーザーが管理者ボタンを一瞬見てしまう瞬間がよく表れます。これは、パーミッション状態が最初のペイントより後に届くことで生じる「未認可UIのフラッシュ」です。修正方法は、パーミッション状態をサーバーレンダリングするか、レンダリング前にプリロードすることです。次のセクションのハンドオフパターンは、設計上この問題に対処しています。

2つのレイヤーは一致している必要がある:パーミッションのドリフト

フロントエンドのパーミッションの状態は、サーバーが許可する内容のキャッシュであり、両者は同期している必要があります。フロントエンドのパーミッション状態がサーバーの強制ルールと乖離すると、2つの障害のいずれかが発生します。サーバーが403を返すボタン(フィッシングに悪用されうるワークフロー)、またはサーバーが許可しているのにUIが表示しないアクション(隠れた機能)です。前者はセキュリティとUXの問題です。機能しないアフォーダンスを表示しており、攻撃者はサーバーが実際に受け入れるものを調べることができます。後者は純粋な機能の損失です。ユーザーは権限があるものにアクセスできません。

ドリフトを防ぐ方法は構造的なものです。単一の信頼できる情報源を定義し、両方のレイヤーにそこからデータを供給します。

ハンドオフパターン

ハンドオフパターンは、サーバーがパーミッションの単一の信頼できる情報源を保持し、クライアントがその情報源のキャッシュを保持する認可アーキテクチャです。キャッシュはログイン時に設定され、インターフェースのレンダリングに使用されますが、あくまで参考情報であり権威的なものではありません。完全な回路は次のように動作します。サーバーがユーザーのパーミッションを決定し、既知のワイヤーフォーマットでクライアントに送信します。クライアントはそれをコンテキストにキャッシュし、UIはキャッシュに基づいてレンダリングし、ユーザーが操作し、サーバーはリクエスト時に再検証します。同じパーミッション識別子がすべてのステップで使用されます。

まずワイヤーフォーマットから始めます。ログイン時(または最初のServer Componentのレンダリング時)に、サーバーはパーミッションセットを送信します。

{
  "userId": "u_8123",
  "permissions": ["tasks:read", "tasks:create", "tasks:delete"]
}

JWTでパーミッションを運ぶ場合、それらはトークンペイロードのクレームとして存在します。署名付きJWT(JWS)は、base64urlエンコードされたペイロードにクレームを保持しており、整合性は保護されていますが暗号化はされていません。署名によってクレームが改ざんされていないことが証明されますが、クレームは秘密ではなくクライアントが読み取れます。暗号化されたJWT(JWE)は異なる構造です。この区別は RFC 7519(JWT仕様) に基づいています。実用的な結論として、JWTのパーミッションクレームはレンダリング用のキャッシュとして十分に機能しますが、それでもあくまでキャッシュに過ぎません。サーバーはアーキテクチャが依拠する信頼できる情報源を使用して、リクエストごとに認可を検証すべきです。

キャッシュをコンテキストに一度だけ供給します。データが最初のペイント時に存在するよう、Server Componentから行うのが理想的です。

// app/layout.tsx — Server Component, Next.js App Router
import { getSessionPermissions } from '@/lib/auth';
import { PermissionsProvider } from '@/hooks/usePermissions';

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const permissions = await getSessionPermissions(); // APIが使用するのと同じソース

  return (
    <html lang="en">
      <body>
        <PermissionsProvider permissions={permissions}>
          {children}
        </PermissionsProvider>
      </body>
    </html>
  );
}
// hooks/usePermissions.tsx
'use client';
import { createContext, useContext } from 'react';

const PermissionsContext = createContext<string[]>([]);

export function PermissionsProvider({
  permissions,
  children,
}: {
  permissions: string[];
  children: React.ReactNode;
}) {
  return (
    <PermissionsContext.Provider value={permissions}>
      {children}
    </PermissionsContext.Provider>
  );
}

export function usePermissions() {
  const permissions = useContext(PermissionsContext);
  return { can: (p: string) => permissions.includes(p) };
}

getSessionPermissions() はRoute Handlerが呼び出すのと同じ関数であるため、UIとゲートは同一のデータを読み取り、tasks:delete は両側で同じ意味を持ちます。パーミッションをServer Componentで読み込む(クライアントの useEffect でフェッチするのではなく)ことで、パーミッションセットは最初のペイント前に利用可能になります。誤ったUIのフラッシュも、チェックが解決するまでのちらつきも発生しません。

React Server ComponentsとServer Actions:バウンダリは構文的なもの

Next.jsのApp Routerでは、「クライアント」と「サーバー」という言葉は、手動で行うネットワーク呼び出しではなく、コードが実行される場所を表します。この区別こそが、Server Actionsが認可を強制する正当な場所となる理由です。データを変更する前にパーミッションチェックを呼び出すNext.jsのServer Actionは、コンポーネントツリーのどこに位置していても、サーバーサイドの認可です。コードはサーバーで実行され、ユーザーは変更できず、クライアントのstateを編集してもチェックを回避することはできません。

現在のNext.js App Routerのドキュメントでは、'use server' ディレクティブはServer Functionをマークします。アクション/ミューテーションのコンテキストで使用されるServer FunctionはServer Actionとも呼ばれます。このディレクティブはバウンダリのマーカーです。その下のコードは、クライアントコンポーネントと並んで .tsx ファイルに記述されていても、サーバーで実行されます。

// app/tasks/actions.ts — Next.js App Router
'use server';

import { getSessionPermissions } from '@/lib/auth';
import { deleteTask } from '@/lib/tasks';

export async function deleteTaskAction(taskId: string) {
  const permissions = await getSessionPermissions(); // サーバーで実行される

  if (!permissions.includes('tasks:delete')) {
    return { ok: false as const, error: 'forbidden', permission: 'tasks:delete' };
  }

  await deleteTask(taskId);
  return { ok: true as const };
}

これはRoute Handlerと同じ tasks:delete チェックが、同じ強制の位置にあります。クライアントのstateを編集して role === 'admin' を偽装しても、ここでは何も変わりません。このアクションはサーバー上でセッションからパーミッションを再導出するからです。Server Actionsはゲートの必要性をなくすものではありません。ゲートが存在するもう一つの場所です。ディレクティブの完全なセマンティクスについては、Next.jsの use server ディレクティブのドキュメントを参照してください。

SPAがサードパーティAPIを直接呼び出す場合

SPAがブラウザから直接サードパーティAPIを呼び出す場合、そのサードパーティが認可目的でのサーバーとなります。決済プロバイダー、ヘッドレスCMS、BaaSは、あなたが実装したかどうかに関わらず、ゲートです。バックエンドを自分で書いていないからといって、強制のバウンダリがなくなるわけではありません。リクエストを評価する者のところへバウンダリが移動するだけです。クライアントサイドのチェックは依然としてUX専用であり、サードパーティの認可ルールがゲートです。自分がコントロールするコードで決定を強制できない場合は、リクエストを自分のバックエンドを通じてルーティングしてください。

障害モード:レイヤーを省略した場合に何が壊れるか

どちらかのレイヤーを省略すると、特定の予測可能な障害が発生します。クライアントのみの認可は簡単に回避でき、サーバーのみの認可はユーザーを混乱させます。どちらも単独では許容できません。

省略されたレイヤー何が壊れるか誰が悪用するかユーザーエクスペリエンス
サーバーサイド(クライアントのみ)認可がまったく強制されないDevToolsや curl を持つ誰でも問題なく見える — 権限のない者にデータが変更されるまでは
クライアントサイド(サーバーのみ)UIがサーバーが拒否するアクションを表示する悪用はない。UXの欠陥ユーザーがボタンをクリックし、403が返り、再度クリックし、諦める

ルールは覚えやすいほど短いです。クライアントのパーミッションでレンダリングし、サーバーのパーミッションで強制し、同じパーミッション識別子を単一の信頼できる情報源から両方に供給します。最初のルールに違反すると、ユーザーは403を返すボタンをクリックし続けます。2番目のルールに違反すると、DevToolsを持つ誰でも自分を昇格させることができます。

認可失敗のUX:403 vs 401

UIが許可されていると思っていたアクションをサーバーが拒否した場合、レスポンスのステータスコードがユーザーに何を表示すべきかを正確に教えてくれます。401はユーザーが認証されていないことを意味し、ログインページへリダイレクトすべきです。403は認証済みだが認可されていないことを意味し、汎用エラーではなくパーミッション拒否の状態を表示すべきです。そうすることで、ユーザーはアクションが失敗した理由を理解できます。これらのセマンティクスは RFC 9110 で定義されています。§15.5.2は401 Unauthorized(認証が必要であり、失敗したか提供されていない)を定義し、§15.5.4は403 Forbidden(サーバーがリクエストを理解したが認可を拒否した)を定義しています。

async function deleteTask(taskId: string) {
  const res = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' });

  if (res.status === 401) {
    window.location.assign('/login');
    return;
  }
  if (res.status === 403) {
    const body = await res.json();
    showPermissionDenied(body.permission); // 例: "タスクを削除する権限がありません。"
    return;
  }
  if (!res.ok) {
    showError('Something went wrong. Try again.');
    return;
  }
  // 成功
}

403を個別に処理することは、クライアント/サーバーのドリフトがユーザーに見える部分です。403レスポンスのセッションリプレイでは、ユーザーがボタンをクリックし、汎用エラーのトーストが表示され、再度クリックする様子がよく見られます。これは、UIがアクションがパーミッション上の理由で拒否されたことを伝えられなかったサインです。サーバーが送信したレスポンスボディのパーミッション名(permission: 'tasks:delete' フィールド)をUIに表示することで、このギャップを埋めることができます。RFCは403が何を意味するかを定義しています。どのように表示するかはあなたの判断ですが、その意味こそがUIが反映すべきものです。

まとめ

認可は2つのバウンダリに存在し、それぞれ異なる役割を担っています。クライアントはインターフェースを形成し、サーバーは何が起こるかを決定し、同じパーミッション識別子がサーバー上の単一の信頼できる情報源から両方に流れます。自分のコードで2つの障害モードを確認してください。サーバーサイドの強制なしにデータの変更をゲーティングするロールまたはパーミッションのチェック(回避される可能性がある)を探し、APIが返す内容からドリフトしたクライアントサイドのチェック(ユーザーが理解できない403)を探してください。チェックがデータを変更する場所では、サーバーがクライアントが書き換えられるものではなく、検証済みセッションから決定を再導出していることを確認してください。

FAQ

クライアントがJWTを読み取れる場合、ユーザーのパーミッションをJWTに保存しても安全ですか?

はい。ただし、トークンをキャッシュとして扱い、セキュリティバウンダリとして扱わない限りは安全です。署名付きJWT(JWS)は、base64urlエンコードされたペイロードにクレームを保持しており、整合性は保護されていますが暗号化はされていません。そのため、クライアントはパーミッションを読み取ることができますが、署名によって改ざんが防止されます。リスクはパーミッションリストの公開ではなく、それを信頼することにあります。サーバーはアプリケーションが使用する認可モデルに従って、すべてのリクエストで認可を検証すべきです。

ページがすでにUIをゲーティングしている場合、Next.jsのServer Actionには独自の認可チェックが必要ですか?

はい。UIのゲーティングはレンダリングされるものを制御するだけであり、ミューテーションを保護しません。Server Actionはサーバーで実行され、それをレンダリングしたページとは独立して呼び出すことができるため、検証済みセッションからパーミッションを再導出し、未認可の呼び出しを自ら拒否する必要があります。'use server' ディレクティブはコードが実行される場所を示すものであり、認可されているかどうかを示すものではありません。Route Handlerと同様に、すべてのServer Actionを強制ポイントとして扱ってください。

ユーザーがログインしているがアクションの権限がない場合、APIはどのステータスコードを返すべきですか?

401 Unauthorizedではなく、403 Forbiddenを返してください。RFC 9110は401を「認証が必要であり、失敗したか提供されていない」と定義し、403を「サーバーがリクエストを理解したが認可を拒否した」と定義しています。権限のないログイン済みユーザーは認証済みですが認可されていない状態であり、これはまさに403です。クライアントは401でログインページにリダイレクトし、403でパーミッション拒否の状態を表示すべきです。これにより、2つの障害が視覚的にも動作的にも区別されます。

フロントエンドで 'role === admin' の代わりに 'tasks:delete' をチェックするのはなぜですか?

パーミッション識別子はロール構造の変更に影響されませんが、ロール名の比較はそうではありません。'role === admin' をチェックすることは、UIを現在のロールの分類体系に結びつけるため、後で adminをadminとbilling-adminに分割すると、すべてのチェックが壊れます。'tasks:delete' をチェックすることは、UIをサーバーが実際に強制するアクションに結びつけるため、ロールが再編成されても変わりません。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.