Windows 11で顕在化したマシンSID重複問題 ― 認証失敗の原因と対策

2025年に入り、一部のWindows環境で「ドメインにログオンできない」「リモートデスクトップ接続が拒否される」「ファイル共有にアクセスできない」といった認証関連の障害が報告されています。これらの事象は、特定の更新プログラム(例:KB5065426など)を適用した後に発生しており、その根本原因の一つとして「マシンSID(マシンID)の重複」が指摘されています。

マシンSIDとは、Windowsが各コンピュータに割り当てる固有の識別子であり、アクセス制御や認証の基礎として利用される重要な情報です。本来はOSインストール時に一意に生成されるものですが、Sysprep(System Preparation Tool)を用いずにディスクイメージを複製した場合などでは、このSIDが複数のマシンで重複することがあります。

これまではマシンSIDの重複によって重大な不具合が起こるケースはほとんどありませんでしたが、Windows 11以降では認証メカニズムの整合性検証が強化され、重複SIDを持つ環境でKerberosやNTLM認証が失敗する事例が明確に確認されています。

本記事では、この問題の背景と技術的な仕組み、発生原因、そして防止策としてのSysprepの重要性について解説します。

マシンSID(マシンID)とは何か

マシンSID(Machine Security Identifier、以下マシンIDとも呼びます)は、Windowsが各コンピュータを識別するために割り当てる固有の識別子です。SID(Security Identifier)はWindowsのセキュリティモデルの基盤を構成する要素であり、ユーザー、グループ、サービス、そしてマシンそのものを一意に識別するために使用されます。

Windowsでは、アクセス制御リスト(ACL)や認証情報の照合においてSIDが参照されます。たとえば、あるフォルダに対してアクセス権を付与すると、その設定はユーザー名ではなく、実際にはユーザーSIDを基準に保持されます。同様に、ローカルコンピュータを識別するためにもSIDが利用され、これが「マシンSID」と呼ばれるものです。

マシンSIDは、Windowsのインストール時に自動的に生成されます。これにより、同一ネットワーク上に複数のマシンが存在しても、それぞれが固有の識別子を持つことになります。しかし、ディスクのクローン作成や仮想マシンのテンプレート展開を行う際に、Sysprep(System Preparation Tool)を使わずにイメージを複製すると、元のSIDがそのまま複製先にも引き継がれ、複数のマシンが同一SIDを共有してしまうことがあります。

一見すると同じ見た目の独立したPCであっても、SIDが重複している場合、Windows内部では「同一マシン」として扱われることがあり、認証やアクセス制御の整合性に問題が生じます。特に、KerberosやNTLMといった認証プロトコルでは、このSIDをもとにマシン間の信頼関係を検証するため、SID重複はログオンエラーや共有アクセスの拒否といった障害を引き起こす要因となります。

つまり、マシンSIDはWindowsのセキュリティ構造を支える根幹的な識別子であり、同一ネットワーク上で重複してはならない値です。運用上は、OSの展開や仮想マシンの複製を行う際に、各マシンが一意のSIDを持つよう管理することが不可欠です。

Sysprepとは何か

(generalize)」するためのプロセスを担います。一般化とは、特定のコンピュータ固有の情報を一時的に削除し、再起動時に新しい環境として再構成できる状態にすることを指します。

Windowsを通常インストールすると、その環境にはマシンSID、ネットワーク設定、デバイスドライバ、イベントログ、ライセンス情報など、ハードウェアやインストール時点に依存した情報が含まれます。これらをそのまま複製して別のマシンに展開すると、同一SIDを持つクローンが複数台生成され、認証やネットワーク上の識別で衝突が発生する可能性があります。Sysprepはこの問題を防ぐため、これらの固有情報を初期化し、次回起動時に新しいSIDや構成情報を自動生成させる役割を果たします。

Sysprepを実行する際には、/generalize オプションを指定するのが一般的です。このオプションにより、マシンSIDを含む固有データが削除され、システムが「未構成状態」となります。その後、再起動時にWindowsが再構成プロセスを実行し、新しい識別子を生成します。これによって、同一イメージから展開された複数のマシンがそれぞれ固有のSIDを持ち、KerberosやNTLMなどの認証メカニズムが正しく動作するようになります。

また、Sysprepは企業や教育機関などで大量のPCを一括展開する際に不可欠な手段です。テンプレートマシンをあらかじめ設定しておき、Sysprepで一般化したイメージを複数のデバイスに展開することで、設定の一貫性と識別の独立性を両立できます。

なお、SysprepはMicrosoftが公式にサポートする唯一の一般化手法であり、過去に存在した「NewSID」などのサードパーティツールは既に非推奨となっています。Sysprepを経ずに複製した環境では、現在のWindows 11以降で報告されているような認証エラーや共有アクセスの不具合が発生する可能性が高いため、運用上は常に一般化済みのイメージを利用することが推奨されます。

マシンSIDの重複が発生するケース

マシンSIDの重複は、Windowsの設計上「SIDがインストール時に一度だけ生成される」という性質に起因します。したがって、同じインストール済みシステムを複数のマシンに複製した場合、すべての複製先が同一のSIDを保持することになります。以下では、実際に重複が発生しやすい典型的なケースを説明します。

1. Sysprepを実行せずにディスクイメージを複製した場合

最も一般的なケースです。

運用現場では、あるマシンを初期設定した後に、その環境をディスクイメージとして他のPCへ複製し、同一構成の端末を短時間で用意することがあります。しかし、Sysprepを実行せずにイメージ化を行うと、元のマシンSIDが複製先にもそのまま引き継がれます。結果として、ネットワーク上で複数のマシンが同じSIDを持つ状態となり、認証処理やアクセス制御に支障をきたす可能性があります。

2. 仮想マシンのテンプレートやスナップショットをそのまま展開した場合

仮想化環境(Hyper-V、VMware、VirtualBox、Proxmox など)では、テンプレートやスナップショットを使って新しい仮想マシンを生成する運用が一般的です。テンプレート作成時にSysprepを実行していない場合、そのテンプレートから派生したすべての仮想マシンが同一SIDを共有します。特にVDI(仮想デスクトップ)やテスト環境では、短時間で多数のインスタンスを立ち上げることが多く、この問題が顕在化しやすくなります。

3. バックアップイメージを別マシンにリストアした場合

システムバックアップを取得し、障害対応や構成複製の目的で別のマシンにリストアする場合にもSID重複は発生します。バックアップにはマシンSIDが含まれており、復元後の環境は元のマシンと同一SIDを持つことになります。特にドメイン参加済みのマシンをこの方法で復元した場合、ドメインコントローラとの信頼関係が失われ、認証エラーを引き起こすことがあります。

4. 非公式ツールでSIDを変更またはコピーした場合

かつては Sysinternals の「NewSID」など、SIDを変更するための非公式ツールが存在しました。しかし、これらのツールは既にMicrosoftのサポート対象外であり、最新のWindowsビルドでは正常に動作しません。さらに、SID以外の関連識別情報(セキュリティデータベースやACL設定など)との整合性を壊す危険性があり、運用環境での使用は推奨されません。

5. 仮想ディスク(VHD/VHDX)を複数の仮想マシンで共有起動した場合

1つの仮想ディスクを複数の仮想マシンで同時に使用する構成でも、SID重複が発生します。ディスク上のシステムは同一のSIDを保持しており、各マシンは内部的に同一識別子として認識されます。そのため、SMB共有や認証トークンの発行時に整合性エラーが発生しやすくなります。

まとめ

マシンSIDの重複は、「OSを新規インストールせず、既存環境をコピー・複製した場合」に必ず発生します。これを防ぐ唯一の確実な方法は、イメージ作成前に Sysprepの/generalizeオプションを実行してSIDを初期化することです。特にドメイン参加環境や仮想化インフラでは、この手順を標準化することが、今後の認証トラブル防止に不可欠です。

なぜ今になって問題が顕在化したのか

マシンSIDの重複は、Windows NTの時代から理論的には存在していた問題です。しかし、長らく実運用上はほとんど問題視されていませんでした。これは、Windowsの認証設計やネットワーク動作が、マシンSIDの重複を直接的に検証することを想定していなかったためです。ところが、Windows 11以降の更新プログラムではセキュリティモデルが強化され、従来黙認されてきた環境が「正しくない構成」として扱われるようになりました。

Windows 10以前では問題が顕在化しなかった理由

Windows 10までのバージョンでは、マシンSIDの重複があっても、通常の運用で深刻な支障が生じることはほとんどありませんでした。ローカル環境では各マシンのアクセス制御リスト(ACL)が個別に管理されており、他のマシンのSIDと衝突しても影響がなかったためです。ドメイン環境でも、認証時にはマシンSIDではなくドメインSIDが優先的に使用されるため、重複は実質的に無視されていました。

このため、過去には「マシンSIDの重複は神話に過ぎない」との見解がMicrosoft自身から示されています。2009年にMark Russinovich(当時Sysinternalsの開発者)が発表したブログ記事「The Machine SID Duplication Myth」では、「同一マシンSIDによる実害は確認されていない」と明言されており、以後も多くの運用現場でSysprepを省略したイメージ展開が行われてきました。

Windows 11以降での変化

しかし、Windows 11(特に24H2以降)では、セキュリティの一貫性検証が強化されました。KerberosやNTLMなどの認証プロトコルにおいて、マシンSIDの一意性がより厳密に参照されるようになり、SIDの重複がある場合には認証を拒否する挙動が導入されています。

特に、2025年8月以降に配信された累積更新プログラム(例:KB5065426など)では、ドメイン参加済みマシンやSMB共有を利用する環境で「SEC_E_NO_CREDENTIALS」や「STATUS_LOGON_FAILURE」といったエラーが頻発する事例が報告されました。Microsoftはこれについて、「重複SIDを持つPC間での認証処理が正しく行えないことを確認した」と公式に説明しています。

背景にあるセキュリティ設計の変化

Windows 11世代では、ゼロトラストモデル(Zero Trust Architecture)の原則に基づき、システム間の信頼関係を明示的に検証する設計へと移行しています。マシンSIDのような基礎的な識別情報についても、これまで以上に厳密な一意性が要求されるようになりました。その結果、これまで問題として顕在化しなかった構成上の欠陥が、セキュリティ上の不整合として表面化したのです。

まとめ

マシンSIDの重複という現象自体は新しいものではありません。しかし、Windows 11およびその後の更新によって、認証処理における整合性検証が強化された結果、「これまで通用していた構成が通用しなくなった」という形で問題が顕在化しました。セキュリティ強化の観点から見れば自然な進化ですが、イメージ展開を前提とする環境では、従来の運用手順の見直しが避けられない状況となっています。

発生している主な事象

マシンSIDの重複によって生じる問題は、主に認証処理の失敗として現れます。特に、Windows Updateの適用後にKerberosまたはNTLM認証が適切に動作しなくなり、ログオンやリモート接続、共有アクセスなどが拒否される事例が確認されています。これらの障害は、企業ネットワークや仮想化環境を中心に報告されており、複数台のマシンが同一SIDを共有している構成で発生する傾向があります。

1. ログオン時の認証エラー

最も多く報告されているのは、ドメインログオンやローカル認証時の失敗です。Windows Update適用後に突然ドメインに参加できなくなり、ユーザーが正しい資格情報を入力してもログオンできない状態となります。イベントログには以下のような記録が出力されます。

  • イベントID 4625(ログオン失敗)
    「An account failed to log on(アカウントのログオンに失敗しました)」というメッセージとともに、Security ID: NULL SID が表示される場合があります。
  • イベントID 4768(Kerberos 認証チケット要求失敗)
    Kerberos 認証でチケットが発行されず、KDC_ERR_PREAUTH_FAILED または SEC_E_NO_CREDENTIALS が記録されることがあります。

これらはいずれも、マシンSIDの整合性が失われ、認証トークンが正しく発行・照合できないことが原因とされています。

2. リモートデスクトップ接続(RDP)の失敗

更新プログラム適用後、リモートデスクトップ(RDP)による接続試行時に「ログオン試行が失敗しました(The logon attempt failed)」というメッセージが表示されるケースがあります。

マシンSIDが他の端末と重複している場合、クライアント認証情報の検証段階で不一致が発生し、セッションが拒否されます。これは、ドメイン内でRDPアクセスを制御している環境(グループポリシーやNTLMベースの認証が関係する環境)で特に発生しやすい事象です。

3. ファイル共有やプリンタ共有へのアクセス不能

マシンSIDの重複により、SMB(Server Message Block)通信で行われる認証が失敗し、ファイル共有やプリンタ共有が利用できなくなる事例も確認されています。ユーザーがネットワーク共有フォルダにアクセスしようとすると、認証ダイアログが繰り返し表示されたり、「アクセスが拒否されました」というエラーが発生します。

Microsoft Q&Aフォーラムでは、特に更新プログラム KB5065426 適用後にこの問題が頻発しており、同一SIDを持つマシン間でSMB共有が機能しなくなることが報告されています。

4. サービスアカウントおよびシステム間通信の不具合

ドメイン参加マシン同士で通信するサービス(たとえばIIS、SQL Server、ファイル同期システムなど)でも、認証トークンが無効化され、接続が確立できないケースがあります。これは、内部的にKerberosチケットやNTLMトークンを利用しているため、マシンSIDの重複によって整合性が失われることに起因します。

5. 再起動後にのみ発生するケース

一部の報告では、更新プログラムの適用直後ではなく、再起動後に問題が発生する傾向が見られます。これは、再起動によって新しいセキュリティトークンが生成される際にSID重複が検出され、認証が拒否されるためと考えられます。

まとめ

以上のように、マシンSIDの重複はWindows 11以降の更新によって顕在化し、以下のような認証関連の不具合として表れています。

  • ドメインログオンの失敗
  • RDP(リモートデスクトップ)接続の拒否
  • SMB共有・プリンタ共有のアクセス不能
  • サービスアカウントによる通信の停止

いずれのケースも、根本的な原因は「同一マシンSIDを持つ複数の端末が存在すること」にあり、これを解消しない限り再発の可能性が高いと考えられます。

影響を受ける認証メカニズム

マシンSIDの重複による認証エラーは、Windowsが採用する複数の認証メカニズムのうち、特にSIDを識別情報として参照する方式に影響を及ぼします。Windowsネットワークでは、ユーザーやマシンを特定する際にSID(Security Identifier)を用いて整合性を検証しており、このSIDが重複していると、認証トークンやチケットの検証に失敗します。

特に影響が顕著なのは、ドメイン環境で利用される Kerberos 認証 と、ワークグループ環境や一部のレガシーシステムで用いられる NTLM 認証 の2種類です。いずれもマシンSIDを認証情報の一部として扱うため、重複したSIDを持つマシン間では「同一マシンである」と誤認されるか、逆に「信頼できない別マシン」として扱われ、認証に失敗します。

以下では、それぞれの認証メカニズムがどのような仕組みで動作し、どのようにマシンSID重複の影響を受けるのかを解説します。

Kerberos認証

Kerberos認証は、Windowsドメイン環境において標準的に使用されているチケットベースの認証方式です。Active Directory(AD)ドメインコントローラ上の認証サービス(KDC: Key Distribution Center)が中心的な役割を担い、ユーザーおよびマシンの身元をチケットの発行によって保証します。

認証の基本的な流れは、クライアントがKDCに対して認証要求を行い、KDCがクライアントのSIDを含む認証チケット(TGT: Ticket Granting Ticket)を発行するというものです。以後の通信では、このチケットを提示することで、クライアントは再度パスワードを送信することなくサーバーリソースへのアクセスを行うことができます。この仕組みにより、Kerberosは高いセキュリティと効率性を両立しています。

しかし、マシンSIDが重複している環境では、この認証フローが破綻します。Kerberosチケットには、マシンのSIDをもとにした識別情報が含まれており、KDCはこれを照合してクライアントの一意性を検証します。もし複数のマシンが同一SIDを共有している場合、KDCはチケットの発行または更新時に整合性を確認できず、認証を拒否することがあります。その結果、ドメインログオンの失敗、リモートデスクトップ(RDP)接続の拒否、SMB共有へのアクセス不能といった事象が発生します。

特にWindows 11以降では、セキュリティモデルが強化され、チケット発行時のSID検証がより厳密に実施されています。そのため、従来のようにマシンSID重複が黙認されるケースは減少し、SIDの整合性が保証されない環境ではKerberos認証そのものが成立しなくなっています。

要するに、Kerberos認証は「一意なマシンSIDを前提とした信頼関係の上に成り立つ仕組み」であり、この前提が崩れると、ドメイン環境におけるあらゆる認証・アクセス制御が機能しなくなるという点に注意が必要です。

H3: NTLM認証

NTLM(NT LAN Manager)認証は、Kerberosが導入される以前からWindowsで使用されてきたチャレンジ・レスポンス方式の認証プロトコルです。現在でも、ワークグループ環境や一部のレガシーシステム、Kerberosが利用できないネットワーク経路においては、後方互換性のために引き続き使用されています。

NTLM認証は、ユーザーのパスワードを直接送信せず、ハッシュ値を用いてサーバー側とクライアント側で相互に整合性を確認することで成り立っています。クライアントがログオン要求を送信すると、サーバーはランダムなチャレンジ値を返し、クライアントはパスワードハッシュを基にレスポンスを計算します。サーバー側では同様の計算を行い、結果が一致すれば認証が成立します。この仕組みにより、平文パスワードがネットワーク上に流れないという利点があります。

しかし、NTLMは設計上、認証の文脈をSID(Security Identifier)に強く依存しています。特に、マシンアカウントやローカルセキュリティコンテキストを用いた認証では、マシンSIDが認証トークンの生成および検証に関与します。そのため、複数のマシンが同一のSIDを共有している場合、サーバー側ではどのクライアントが本来のリクエスト元であるかを識別できず、結果として「資格情報が無効」「認証に失敗しました」といったエラーを返すことになります。

この問題は特に、SMB(Server Message Block)を利用したファイル共有やプリンタ共有など、NTLM認証を前提とする通信で顕著に現れます。Windows Update(例:KB5065426)以降では、セキュリティ検証が強化されたことにより、同一SIDを持つマシン間でのNTLM認証が明示的に拒否されるようになりました。その結果、従来は動作していた共有フォルダへの接続やリモートリソースのアクセスが突然不能になる事例が多数報告されています。

つまり、NTLM認証においてもKerberosと同様に、マシンSIDの一意性は前提条件です。SIDが重複した環境では、認証トークンの信頼性が損なわれ、ネットワーク越しの認証全体が破綻します。現行のWindowsでは、このような構成がセキュリティ上「不正な状態」として検出されるようになっており、今後はNTLMベースの環境でもSysprepによるSID初期化が不可欠となっています。

どのように対策すべきか

マシンSIDの重複による認証失敗は、Windowsの設計そのものに起因する構造的な問題であるため、根本的な対策は「各マシンが一意のSIDを持つように構成を見直すこと」に尽きます。特に、Sysprepを用いずにディスクイメージや仮想マシンを複製している場合は、展開プロセスの修正が必要です。以下に、代表的な対策手順と運用上の注意点を示します。

1. Sysprepを用いたイメージの一般化

最も基本的かつ確実な対策は、Sysprep(System Preparation Tool)による一般化(/generalize)を実施することです。

Sysprepを実行すると、マシンSIDを含む固有情報が初期化され、次回起動時に新しいSIDが自動的に生成されます。これにより、複数のマシンが同一イメージから展開されたとしても、それぞれが固有の識別子を持つ状態になります。

実行例(管理者権限のコマンドプロンプトで実行):

sysprep /generalize /oobe /shutdown

/generalize はSIDを初期化するオプション、/oobe は初回セットアップ画面を有効化するオプションです。Sysprep実行後に取得したイメージをテンプレートとして利用すれば、安全に複製展開が可能になります。

2. 既存環境でのSID確認と再展開の検討

すでに多数の端末や仮想マシンを展開済みの場合、まず現状のSIDを確認し、重複が存在するかを把握することが重要です。SIDはSysinternalsツールの PsGetSid や PowerShellコマンドを用いて確認できます。

例:

PsGetSid.exe

または

Get-WmiObject Win32_ComputerSystemProduct | Select-Object UUID

もし同一SIDのマシンが複数確認された場合、再度Sysprepを実施してSIDを再生成するか、OSを再インストールすることが推奨されます。SIDの一部のみを変更する非公式ツールやレジストリ操作は、セキュリティデータベースとの不整合を引き起こす可能性があるため避けるべきです。

3. テンプレートおよび自動展開手順の見直し

仮想化基盤やクローン展開を行う運用では、テンプレート作成時点でのSysprep実施を標準化することが不可欠です。特にVDI環境、Hyper-VやVMwareでのゴールデンイメージ管理、またはクラウド上の仮想マシン展開(Azure、AWSなど)においては、イメージ作成後の「一般化」を怠ると、すべてのインスタンスが同一SIDを共有するリスクがあります。

運用ルールとして、イメージ化前に /generalize を含むSysprep実行を義務化し、テンプレート更新時にその状態を維持することが望ましいです。

4. 一時的な回避策(推奨されない方法)

Microsoftは一部の環境向けに、重複SIDチェックを一時的に無効化するグループポリシーやレジストリ設定を案内しています。しかし、これらはあくまで暫定的な回避策であり、セキュリティリスクを伴います。SID重複自体は解消されないため、将来的な更新で再び認証エラーが発生する可能性が高く、恒久的な解決策にはなりません。

根本的な修正を行うまでの一時的措置としてのみ利用すべきです。

5. 運用ポリシーと検証プロセスの整備

今回の問題を教訓として、イメージ配布やシステム複製のプロセスを運用ポリシーとして明文化し、更新や配布前に検証を行う体制を整えることが望まれます。

特に以下の点を定期的に確認することが効果的です。

  • テンプレート作成時にSysprepが確実に実行されているか。
  • 展開済みのマシンでSIDが重複していないか。
  • 新しいWindows更新プログラムの適用後に認証エラーが発生していないか。

まとめ

マシンSID重複による認証失敗は、Windows 11以降のセキュリティ強化によって顕在化した構成上の不備です。最も有効な対策は、Sysprepによるイメージの一般化を徹底することです。既存環境では、SIDの重複を早期に検出し、再展開やテンプレート修正を通じて正常な識別体系を再構築することが求められます。

運用の効率化とセキュリティの両立のためには、イメージ管理手順を体系的に見直し、SID一意性の確保を組織的な標準として維持することが不可欠です。

おわりに

マシンSIDの重複は、Windowsの仕組み上、古くから存在する潜在的な問題でした。しかし、Windows 11以降の更新プログラムにおいて認証処理の厳格化が進んだ結果、これまで見過ごされてきた構成上の不備が明確な障害として顕在化しました。特に、KerberosやNTLMといったSIDに依存する認証方式においては、重複したSIDを持つマシン間で認証トークンの整合性が失われ、ログオンや共有アクセスの失敗といった深刻な影響が発生しています。

この問題の根本原因は、Sysprepを用いずにディスクイメージや仮想マシンを複製することにあります。Sysprepを実行せずに展開された環境では、複数のマシンが同一の識別子を持つことになり、Windowsのセキュリティモデルが前提とする「一意なSIDによる信頼関係」が崩壊します。その結果、認証基盤が正しく機能しなくなるのです。

対策としては、イメージ展開時に Sysprepの/generalizeオプションを必ず実行すること、および既存環境でSID重複が疑われる場合には PsGetSidなどを用いて確認し、再展開または再構成を行うこと が推奨されます。また、仮想化や自動デプロイを行う運用環境では、テンプレート作成時に一般化プロセスを標準化し、再利用するすべてのイメージが一意のSIDを生成できる状態であることを保証することが重要です。

本件は、単なる一時的な不具合ではなく、Windowsのセキュリティ設計の根幹に関わる構成管理上の問題です。今後の環境構築においては、効率性だけでなく、SIDの一意性を含むセキュリティ整合性の維持を重視した運用へと移行することが求められます。

参考文献

Next.js + Prisma で PostgreSQL の Row Level Security を試す

近年、バイブコーディングや個人開発の現場において、Next.js と Supabase を組み合わせたアプリケーション開発が急速に広がっています。

Supabase は、PostgreSQL を基盤とした BaaS(Backend as a Service)であり、認証やストレージ、データベース操作といった機能を短時間で導入できる点が魅力です。Next.js と併用することで、フロントからバックエンドまでを一気通貫で実装できるため、特に個人開発やスタートアップにとって非常に有用な選択肢となっています。

しかし一方で、この手軽さがセキュリティ上のリスクを生むケースも少なくありません。

特に懸念されるのは、Row Level Security(RLS)を適切に設定していないことによって、アプリケーションの利用者が他のユーザーのデータにアクセスできてしまう脆弱性です。実際、海外の開発者ブログやSNS上でも、Supabase を利用したプロジェクトで「認可設定が甘く、ユーザーデータが丸見えになっていた」といった事例が報告されています。これは単純な実装ミスであると同時に、「DB レイヤーでのアクセス制御を軽視した設計」が引き起こす典型的な問題でもあります。

アプリケーションコードの中で「where 句」を書き忘れたり、認証の条件分岐が抜けてしまったりすることは、人間がコードを書く以上どうしても起こり得ます。そうしたヒューマンエラーを補完し、データの安全性を保証するために有効なのが、PostgreSQL が備える Row Level Security(RLS) です。RLS は、テーブルごとに「誰がどの行を参照・更新できるのか」をポリシーとして定義でき、アプリケーション層のバグに左右されず、データベース側で強制的に境界を守ることができます。

本記事では、Supabase の文脈で話題に上がることの多い RLS を、より基盤寄りの構成(Next.js + Prisma + Docker Compose + PostgreSQL)で実際に構築し、その有効性を確認していきます。

認証セッションや JWT といった仕組みと組み合わせることで、開発規模が大きくなっても安全性を確保できる堅牢なアプリケーション設計が可能になります。

この記事を通して読者の方に伝えたいのは、「アプリ層だけでなくデータベース層でもセキュリティ境界を確立することの重要性」です。Next.js や Supabase を利用して個人開発やスタートアップ開発を進めている方にとっても、よりセキュアな設計を実践する上で参考となるはずです。

Row Level Security(RLS)とは

PostgreSQL が提供する Row Level Security(RLS) は、テーブルごとに行レベルでアクセス制御を行う仕組みです。通常はアプリケーション側で「WHERE 句」を付与してユーザーごとのデータ制限を実現しますが、この方法だとコードの書き漏らしやバグによって他人のデータにアクセスできてしまう可能性があります。RLS を使えば、データベース自身が行単位でアクセス制御を強制するため、アプリケーション層の不備を補完できるのが大きな特徴です。

どのバージョンから利用できるのか

RLS は PostgreSQL 9.5(2016年リリース) から導入されました。

その後、9.6 以降では細かな機能改善が続き、現在の最新バージョン(15, 16, 17 系列)でも標準機能として利用できます。

つまり、近年のほとんどの PostgreSQL 環境(Supabase や Cloud SQL などのマネージドサービスを含む)では、追加モジュールを導入することなくすぐに RLS を有効化できます。

仕組みの概要

  • 有効化 各テーブルごとに ENABLE ROW LEVEL SECURITY を指定すると RLS が有効になります。さらに FORCE ROW LEVEL SECURITY を付けることで、スーパーユーザーを除くすべてのクエリにポリシーが強制されます。
  • ポリシー定義 CREATE POLICY を使って「どの条件を満たす行を参照できるか/更新できるか」を定義します。 たとえば、company_id がセッション変数に一致する行だけを返すようにすれば、ユーザーは自分の会社のデータしか操作できなくなります。
  • 参照と更新の区別 ポリシーは USING(参照可能な行の条件)と WITH CHECK(挿入・更新できる行の条件)の二種類を持ちます。これにより、読み取りと書き込みの制御をきちんと分けて設定できます。

活用されるシーン

  • マルチテナント型のSaaS 1つのデータベースに複数企業のデータを格納する場合、RLS を使うことで「他社のデータを見られない」という保証をDB側で確実に実現できます。
  • 個人向けサービス 個別ユーザーごとに独立したデータを保持する場合、user_id 単位で RLS を設定すれば、本人以外はアクセスできません。
  • セキュリティ要件が厳しいシステム アプリ層のバグや抜け漏れがあっても、DB側で強制的に境界を守れることは監査や法令遵守の観点でも重要です。

なぜ注目されているのか

Supabase の普及によって、PostgreSQL 標準機能である RLS の存在が一般開発者にも広く知られるようになりました。しかし一方で、RLS を有効化していなかったりポリシーが適切でなかったために他ユーザーのデータが閲覧可能になる事故が報告されるケースも見られます。

このような背景から、個人開発やスタートアップ開発でも RLS を意識的に取り入れるべきという認識が高まっています。

動作確認の流れ

本記事で紹介するサンプルは、Next.js + Prisma + PostgreSQL(Docker Compose) という構成をベースにしています。ここでは細かいコードは割愛し、全体像を段階的に示します。

まず最初に、フロントエンドとバックエンドの統合的な実装基盤として Next.js プロジェクトを用意します。Next.js はフロントエンドフレームワークという印象が強いですが、Route Handlers や Server Actions を利用することで、バックエンド API を容易に組み込むことができます。今回は画面を構築せず、API サーバーとしての役割に集中させます。

次に、ORM として Prisma を導入します。Prisma を使うことで、データベース操作を型安全に行え、マイグレーションやクエリ管理も容易になります。Next.js との統合もしやすく、開発効率を高められる選択肢です。

データベースには PostgreSQL を採用し、ローカル環境では Docker Compose で起動します。コンテナを利用することで環境差異を減らし、CI/CD パイプラインでも再現しやすくなります。ここで重要なのは、アプリケーション接続用のデータベースユーザーを 非スーパーユーザー として作成することです。これにより、常に RLS が適用される安全な環境を構築できます。

環境が整ったら、Prisma のスキーマ定義を通じて company と user の2つのモデルを設計します。マイグレーションを実行することで実際のテーブルが作成され、RLS を適用できる状態が整います。

続いて、PostgreSQL 側で RLS を設定します。各テーブルに対して「どの会社に属するデータにアクセスできるか」をポリシーとして定義し、アプリケーション側からはセッション変数経由で company_id を渡します。これにより、アプリケーションコードの不備があってもデータベースが境界を守り続ける構成となります。

最後に、Next.js の Route Handlers で CRUD API を実装し、Postman などのツールを使って動作確認を行います。会社ごとに返却されるデータが異なることを確認できれば、RLS が正しく効いていることが証明されます。

ステップ一覧

1. Next.js プロジェクトの作成 → フロント兼バックエンドの基盤を用意
2. Prisma の導入と初期化 → ORM として採用し、DB操作の型安全性とマイグレーション管理を担保
3. Docker Compose による PostgreSQL の起動 → 非スーパーユーザー(NOBYPASSRLS付き)を用意し、安全な接続ユーザーを確保
4. Prisma スキーマの定義 → company と user モデルを記述し、マイグレーションでテーブルを生成
5. RLS の設定 → PostgreSQL 側にポリシーを定義し、行レベルでアクセス制御を強制
6. API 実装(Next.js Route Handlers) → CRUD API を構築し、セッション変数によって RLS を効かせる
7. 動作確認 → 会社ごとに返却データが異なることを確認し、RLS が有効であることを検証

プロジェクト構築と Prisma 導入

本記事では、Next.js をベースとしたプロジェクトに Prisma を導入し、PostgreSQL と接続できる状態を整えます。ここでは、実際のコマンドや設定コードを差し込む場所を示し、流れの全体像を整理していきます。

1. Next.js プロジェクトの新規作成

まずは Next.js プロジェクトを新規作成します。

ここで紹介するケースでは、画面部分は利用せず API 実装を中心とするため、Route Handlers を活用したバックエンド API サーバーとして Next.js を利用します。

> npx create-next-app@latest next-rls-prisma
[質問にはすべてデフォルトで回答]
> cd next-rls-prisma

2. Prisma の導入

次に、Prisma をプロジェクトに導入します。Prisma はモダンな ORM であり、型安全なクエリの提供やマイグレーション管理を通じて、開発効率と安全性を高めてくれます。

> npm i -D prisma
> npm i @prisma/client

3. Prisma の初期化

Prisma を導入したら、初期化を行います。この操作により.envファイルとprisma/schema.prismaファイルが生成されます。

.envは接続情報を定義する環境変数ファイル、schema.prismaはデータベーススキーマを記述する中心的な設定ファイルとなります。

> npx prisma init

ここまで完了すれば、Next.js プロジェクトと Prisma の接続準備が整い、次の章で行う Docker Compose による PostgreSQL の環境構築に進むことができます。

Docker Compose でデータベースを構築し、.env を設定する

Next.js プロジェクトと Prisma の準備ができたら、次はローカル環境で利用する PostgreSQL を Docker Compose を使って立ち上げます。コンテナを使うことで環境構築が容易になり、チーム開発や CI 環境でも再現性を担保できます。

本記事では、アプリケーション接続用に 非スーパーユーザー(RLS バイパス不可のユーザー) を作成するように初期化スクリプトを設定します。これにより、後のステップで RLS を適用した際に確実に効かせられる安全な環境を用意できます。

1. docker-compose 設定ファイルの用意

まずはcompose.yamlを作成し、PostgreSQL サービスを定義します。

ここでは、初期化スクリプトを配置するフォルダを指定しておくことで、アプリケーション用ユーザーを自動的に作成できるようにします。

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: password
      POSTGRES_DB: appdb
    ports:
      - "5432:5432"
    volumes:
      - ./initdb:/docker-entrypoint-initdb.d

2. 初期化スクリプトの配置

Docker 公式の PostgreSQL イメージは、/docker-entrypoint-initdb.d/ 配下に配置された SQL ファイルを初回起動時に実行してくれます。この仕組みを利用して、アプリケーション用のユーザー(例: app_rw)を作成し、必要な権限を与えます。

-- アプリ用:非superuser・RLSバイパス不可・migrate用にCREATEDBを付与
CREATE ROLE app_rw LOGIN PASSWORD 'app_rw_password'
  NOSUPERUSER NOBYPASSRLS CREATEDB;

-- publicスキーマの利用 + 作成を許可(← これが無いとテーブル作成できない)
GRANT USAGE, CREATE ON SCHEMA public TO app_rw;

-- 既存オブジェクトへの権限
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES    IN SCHEMA public TO app_rw;
GRANT USAGE, SELECT                  ON ALL SEQUENCES IN SCHEMA public TO app_rw;

-- これから「app_rw が作成する」オブジェクトに自動付与(明示しておく)
ALTER DEFAULT PRIVILEGES FOR ROLE app_rw IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;

ALTER DEFAULT PRIVILEGES FOR ROLE app_rw IN SCHEMA public
  GRANT USAGE, SELECT ON SEQUENCES TO app_rw;

3. .env の設定変更

次に、Prisma が利用する .env の DATABASE_URL を、先ほど作成したアプリケーション用ユーザーで接続するように変更します。

DATABASE_URL="postgresql://app_rw:app_rw_password@localhost:5432/appdb?schema=public"

このステップを終えることで、Next.js + Prisma プロジェクトから PostgreSQL に接続可能な状態が整います。次の章からは、Prisma スキーマを編集し、実際にマイグレーションを実行してテーブルを作成していきます。

company / user モデルを追加し、マイグレーションを実行する

この章では、RLS をかける前段として company と user の2モデルを Prisma スキーマに追加します。テーブル/カラム名は運用で扱いやすい snake_case に統一し、主キーは cuid(ハイフンなしの文字列ID) を採用します。

1. Prisma スキーマにモデルを追加

companyモデルとuserモデルを定義します。

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

// 企業モデル
model company {
  id   String @id @default(cuid())
  name String

  users user[]
}

// ユーザーモデル
model user {
  id         String  @id @default(cuid())
  name       String
  company_id String
  company    company @relation(fields: [company_id], references: [id])
}

注意:Prisma を初期化したときに generator client に output 行が含まれていることがあります。これは削除してください。デフォルト設定を利用すれば Prisma Client は node_modules/.prisma/client に生成され、アプリ側からは import { PrismaClient } from “@prisma/client”; で問題なく利用できます。独自の出力先を指定すると環境ごとにパスがずれて不具合を起こすため、あえて残す理由がない限り削除するのが安全です。

2. マイグレーションを作成・適用

スキーマの変更をデータベースに反映します。

> npx prisma migrate dev --name init

マイグレーションを実行すると以下が行われます。

  • prisma/migrations/<timestamp>__init/ディレクトリが生成される
  • DB にcompany / userテーブルが作成される
  • Prisma Client が自動生成され、アプリから利用できる状態になる

注意:マイグレーション時には .env の DATABASE_URL が正しく app_rw(非スーパーユーザー、NOBYPASSRLS 付き、USAGE, CREATE ON SCHEMA public 権限あり)を指していることを確認してください。これが誤っていると「permission denied for schema public」などのエラーになります。

3. テーブル作成の確認

テーブルが作成されているかを確認します。Prisma Studioを使う方法が簡単です。

> npx prisma studio

これで RLS を適用できる土台(company / user テーブル) が整いました。

次の章では、PostgreSQL 側で RLS を有効化し、ポリシーを定義する手順に進みます。

RLS を適用するマイグレーションを追加する

この章では、すでに作成した company / user テーブルに対して Row Level Security(RLS) を有効化し、会社境界(company_id)でのデータ分離をポリシーとして設定します。以降、アプリケーションからはセッション変数で会社IDを注入することで、クエリに WHERE を書かずとも DB 側で行レベルの制御が強制されるようになります。

1. RLS 用のマイグレーション雛形を作る

RLS は Prisma のスキーマ記法では表現できないため、生の SQL を含むマイグレーションを作ります。まず “空の” マイグレーションを発行します。

> npx prisma migrate dev --name add-rls-user

これでprisma/migrations/<timestamp>__add-rls-user/migration.sqlが生成されます。

2. 生成されたマイグレーションスクリプトに RLS の SQL を追記

user テーブルに対して RLS を 有効化(ENABLE)強制(FORCE) し、company_id がセッション変数に一致する行のみ許可するポリシーを定義します。

セッション変数名は名前衝突を避けるため app.company_id のようにプレフィックスを付けるのが安全です。

-- UserテーブルにRLSを設定(会社境界で制限)
ALTER TABLE "user" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "user" FORCE ROW LEVEL SECURITY;

CREATE POLICY user_by_company ON "user"
  FOR ALL
  USING      (company_id = current_setting('app.company_id', true))
  WITH CHECK (company_id = current_setting('app.company_id', true));

3. マイグレーションを適用する

追記が終わったら、DB に適用します。

> npx prisma migrate dev

もしシャドーDB作成が必要な構成で、アプリ接続ユーザーに CREATEDB を付与していない場合は、schema.prisma の datasource に shadowDatabaseUrl を設定して superuser を使う運用にしておくと安定します(この章では設定コードは割愛、前章の方針どおりでOK)。

4. RLS が適用されたかを確認する

以下は psql から確認する手順です。アプリ接続用の 非スーパーユーザー(例: app_rw) で接続して実行してください。

4.1. 接続

# 例: docker compose で起動している場合
> docker compose exec -T db psql -U app_rw -d appdb

もしスーパーユーザーで入る場合は、各セッションで先に SET row_security = on; を実行してください(superuserは既定でRLSをバイパスするため)。

4.2. RLS の有効化・強制状態を確認

-- RLSフラグ(有効/強制)の確認
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND relname = 'user';

-- 付与済みポリシーの確認
SELECT schemaname, tablename, policyname, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'public' AND tablename = 'user';
  • relrowsecurity = t かつ relforcerowsecurity = t であること
  • user テーブルに company_id = current_setting(‘app.company_id’, true) を条件とするポリシーが載っていること

4.3. セッション変数なしだと行が見えないことを確認

-- セッション変数未設定の状態
SELECT * FROM "user";

期待:0 行(または権限エラー)。

理由:USING (company_id = current_setting(‘app.company_id’, true)) が満たせないため。

アプリ接続ユーザーは 非スーパーユーザー(NOBYPASSRLS) を使用してください。superuser で接続する場合は SET row_security = on; を入れないと RLS が適用されません(本番運用では非superuserが原則)。

4.4. つまづかないための事前注意(簡潔に)

  • テーブル・カラム名と SQL の表記を一致させる(snake_case で統一)。
  • FORCE を付けることで、所有者や誤設定によるバイパスを防ぐ。
  • セッション変数名に app. プレフィックスを付ける(カラム名と混同しないため)。
  • 非superuser + NOBYPASSRLS のアプリユーザーで接続する(compose の init スクリプトで作成済み想定)。

バックエンド API を作る(PrismaClient 準備 → CRUD 実装)

RLS を効かせるために、API から DB へアクセスする際は トランザクション先頭で set_config(‘app.company_id’, …) を実行する方針にします。今回は検証しやすいように、認証の代わりに x-company-id ヘッダで会社IDを受け取り、その値を set_config に渡します(※本番ではセッション/JWTから注入)。

1. PrismaClient の作成(共通モジュール)

Next.js から Prisma を再利用できるよう、シングルトンの PrismaClient を用意します。

  • ファイル:/lib/prisma.ts
  • 目的:開発中のホットリロードで複数インスタンスが出来ないようにする。ログ設定などもここで。
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query", "warn", "error"] : ["error"],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

2. API 仕様の方針

  • ベースURL:/api
  • リソース:/companies(管理用:RLSなし), /users(RLS対象)
  • テナント切り替え:x-company-id ヘッダ(users系のみ必須
  • 例外方針:RLSで見えない行は 404 と等価の扱いにする(更新/削除も同様)

3. ディレクトリ構成

app/
  api/
    companies/
      route.ts        # GET(list), POST(create)
      [id]/
        route.ts      # GET(read), PATCH(update), DELETE(delete)
    users/
      route.ts        # GET(list), POST(create)  ← RLS適用(要ヘッダ)
      [id]/
        route.ts      # GET, PATCH, DELETE       ← RLS適用(要ヘッダ)
lib/
  prisma.ts

4. Companies API(管理用:RLSなし)

4.1. 一覧 & 作成

  • ファイル:app/api/companies/route.ts
  • ハンドラ
    • GET /api/companies?skip=0&take=50(ページング)
    • POST /api/companies(body: { name: string })
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

// GET /api/companies?skip=0&take=50
export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const skip = Number(searchParams.get("skip") ?? "0");
  const take = Math.min(Number(searchParams.get("take") ?? "50"), 200);

  const [items, total] = await Promise.all([
    prisma.company.findMany({ skip, take, orderBy: { name: "asc" } }),
    prisma.company.count(),
  ]);

  return NextResponse.json({ items, total, skip, take });
}

// POST /api/companies  body: { name: string }
export async function POST(req: NextRequest) {
  const body = await req.json().catch(() => null) as { name?: string } | null;
  if (!body?.name) {
    return NextResponse.json({ error: "name is required" }, { status: 400 });
  }

  const company = await prisma.company.create({ data: { name: body.name } });
  return NextResponse.json(company, { status: 201 });
}

4.2. 参照・更新・削除

  • ファイル:app/api/companies/[id]/route.ts
  • ハンドラ
    • GET /api/companies/:id
    • PATCH /api/companies/:id(body: { name?: string })
    • DELETE /api/companies/:id
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET(
  _req: NextRequest,
  { params }: { params: { id: string } }
) {
  const company = await prisma.company.findUnique({ where: { id: params.id } });
  if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
  return NextResponse.json(company);
}

export async function PATCH(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const body = await req.json().catch(() => null) as { name?: string } | null;
  if (!body) return NextResponse.json({ error: "invalid json" }, { status: 400 });

  try {
    const updated = await prisma.company.update({
      where: { id: params.id },
      data: { ...(body.name ? { name: body.name } : {}) },
    });
    return NextResponse.json(updated);
  } catch {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
}

export async function DELETE(
  _req: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    await prisma.company.delete({ where: { id: params.id } });
    return NextResponse.json({ ok: true });
  } catch {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
}

5. Users API(RLS対象:x-company-id 必須)

5.1. 一覧 & 作成

  • ファイル:app/api/users/route.ts
  • ヘッダ:x-company-id: <company_id>(必須)
  • ハンドラ
    • GET /api/users?skip=0&take=50
      1. ヘッダ検証 → 2) $transaction 開始 → 3) set_config(‘app.company_id’, companyId, true) → 4) findMany と count
    • POST /api/users(body: { name: string })
      1. ヘッダ検証 → 2) $transaction + set_config → 3) create({ data: { name, company_id: companyId } }) ※ WITH CHECK が効くため、万一クライアントが別の company_id を送っても DB が拒否
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

// GET /api/users?skip=0&take=50
export async function GET(req: NextRequest) {
  const companyId = req.headers.get("x-company-id");
  if (!companyId) {
    return NextResponse.json({ error: "x-company-id header required" }, { status: 400 });
  }

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`select set_config('app.company_id', ${companyId}, true)`;

    const { searchParams } = new URL(req.url);
    const skip = Number(searchParams.get("skip") ?? "0");
    const take = Math.min(Number(searchParams.get("take") ?? "50"), 200);

    const [items, total] = await Promise.all([
      tx.user.findMany({ skip, take, orderBy: { name: "asc" } }),
      // RLS が効くので count も自動で同じ境界に制限される
      tx.user.count(),
    ]);

    return NextResponse.json({ items, total, skip, take });
  });
}

// POST /api/users  body: { name: string, company_id?: string }
export async function POST(req: NextRequest) {
  const companyId = req.headers.get("x-company-id");
  if (!companyId) {
    return NextResponse.json({ error: "x-company-id header required" }, { status: 400 });
  }

  const body = await req.json().catch(() => null) as { name?: string; company_id?: string } | null;
  if (!body?.name) {
    return NextResponse.json({ error: "name is required" }, { status: 400 });
  }

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`select set_config('app.company_id', ${companyId}, true)`;

    // 安全のため、API入力の company_id は無視してサーバ側で上書き
    const created = await tx.user.create({
      data: { name: body.name, company_id: companyId },
    });

    return NextResponse.json(created, { status: 201 });
  });
}

5.2. 参照・更新・削除

  • ファイル:app/api/users/[id]/route.ts
  • ハンドラ
    • GET /api/users/:id → $transaction + set_config → findUnique。RLSにより他社IDは見えない=404相当
    • PATCH /api/users/:id(body: { name?: string }) → $transaction + set_config → update。RLS条件を満たさないと対象0件=404
    • DELETE /api/users/:id → $transaction + set_config → delete。同上
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

// GET /api/users/:id
export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const companyId = req.headers.get("x-company-id");
  if (!companyId) {
    return NextResponse.json({ error: "x-company-id header required" }, { status: 400 });
  }

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`select set_config('app.company_id', ${companyId}, true)`;

    const user = await tx.user.findUnique({ where: { id: params.id } });
    if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 });
    return NextResponse.json(user);
  });
}

// PATCH /api/users/:id  body: { name?: string }
export async function PATCH(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const companyId = req.headers.get("x-company-id");
  if (!companyId) {
    return NextResponse.json({ error: "x-company-id header required" }, { status: 400 });
  }
  const body = await req.json().catch(() => null) as { name?: string } | null;
  if (!body) return NextResponse.json({ error: "invalid json" }, { status: 400 });

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`select set_config('app.company_id', ${companyId}, true)`;

    try {
      const updated = await tx.user.update({
        where: { id: params.id },
        data: { ...(body.name ? { name: body.name } : {}) },
      });
      return NextResponse.json(updated);
    } catch {
      // RLSに弾かれた or 存在しない
      return NextResponse.json({ error: "Not found" }, { status: 404 });
    }
  });
}

// DELETE /api/users/:id
export async function DELETE(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const companyId = req.headers.get("x-company-id");
  if (!companyId) {
    return NextResponse.json({ error: "x-company-id header required" }, { status: 400 });
  }

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`select set_config('app.company_id', ${companyId}, true)`;

    try {
      await tx.user.delete({ where: { id: params.id } });
      return NextResponse.json({ ok: true });
    } catch {
      return NextResponse.json({ error: "Not found" }, { status: 404 });
    }
  });
}

6. 動作確認

会社を2つ作成します。

POST http://localhost:3000/api/companies
Body: { "name": "Acme" }
→ id をメモ
POST http://localhost:3000/api/companies
Body: { "name": "Globex" }
→ id をメモ

次にそれぞれの会社にユーザーを作成します。

POST http://localhost:3000/api/users
Headers: x-company-id: <Acme社のid>
Body: { "name": "Alice" }
→ 201 Created
POST http://localhost:3000/api/users
Headers: x-company-id: <Globex社のid>
Body: { "name": "Bob" }
→ 201 Created

それぞれのユーザー一覧が取得できることを確認します。

GET http://localhost:3000/api/users
Headers: x-company-id: <Acme社のid>
→ [ { name: "Alice", company_id: <Acme社のid> } ] のみ取得できることを確認
GET http://localhost:3000/api/users
Headers: x-company-id: <Globex社のid>
→ [ { name: "Bob", company_id: <Globex社のid> } ] のみ取得できることを確認

最後に、ユーザーと企業が一致しないケースではデータが取得できないことを確認します。

GET http://localhost:3000/api/users/<Aliceのid>
Headers: x-company-id: <Acme社のid>
→ [ { name: "Alice", company_id: <Acme社のid> } ] のみ取得できることを確認
GET http://localhost:3000/api/users/<Aliceのid>
Headers: x-company-id: <Globex社のid>
→ 404 Not Foundになることを確認

7. 実際に使用する際のメモ

  • x-company-id はデモ用。本番は認証セッション/JWTから company_id を取得
  • 管理者ロールを導入する場合は set_config(‘app.is_admin’,’true’,true) を追加し、RLSポリシーに OR 条件を拡張

まとめ

本記事では、PostgreSQL の Row Level Security(RLS)を Next.js + Prisma 環境で適用する方法を、一から順を追って解説しました。

まず、RLS とは何か、その背景やどのバージョンから利用できるのかといった基礎知識を整理し、データベース側で強制的に行レベルのアクセス制御を行う重要性を確認しました。続いて、Next.js プロジェクトを新規作成し、Prisma を導入してローカル環境に PostgreSQL を Docker Compose で構築しました。さらに、company / user モデルを設計し、マイグレーションによって実際のテーブルを作成。その上で、RLS を有効化してポリシーを設定し、会社単位でデータが分離される仕組みを確認しました。

最後に、PrismaClient を使って Next.js の Route Handlers に CRUD API を実装し、x-company-id ヘッダを通じてセッション変数を注入することで、アプリケーション層の記述に依存せず DB 側で安全に境界を守る仕組みを完成させました。Postman での検証を通じて、会社ごとに結果が切り替わることや、他社データにはアクセスできないことを確認できました。

RLS は アプリ層のミスをデータベース層でカバーできる強力な仕組みです。とりわけマルチテナントの SaaS やセキュリティ要件の高いサービスでは、導入する価値が非常に大きいといえます。Supabase を利用する個人開発でも、Next.js + Prisma を利用するチーム開発でも、「RLS を前提とした設計」を意識することが、今後ますます重要になるでしょう。

これから RLS を試してみようと考えている方は、ぜひ本記事の流れを参考にして、まずはローカル環境で小さなサンプルを動かすところから始めてみてください。

参考文献

モバイルバージョンを終了