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

参考文献

PostgreSQLでLIMIT句を使ったサーバーサイドページネーションは実現できるか

きっかけ

REST API でサーバーサイドページネーションを実現したいと考えたのですが、指定したページのデータをデータベースから効率よく検索できないのであれば、一回ですべてを取得した方が結果的に効率的になります。

クライアントのスペックをそれなりに見込むことができれば、クライアントサイドページネーションの方が効率的ですが、ヒットする件数が膨大だったり、1レコードのデータ量が多い場合はそれも難しくなるため、何らかの形でサーバーサイドページネーションを検討する必要がでてきます。

ChatGPT に REST API でのサーバーサイドページネーションの実現方法について訊いたところ、以下の3つの方式を提案されました。

オフセットベースページネーション

GET /items?offset=20&limit=10

特徴

  • 利点:実装が簡単。UI側でも扱いやすい。
  • 欠点:大量のデータでページ数が大きいと、パフォーマンスが低下(インデックスが効かなくなる可能性)。

もっともよく見かけるパターンだと思います。どこから取得するか(offset)と最大何件取得するか(limit)で指定する方法になります。そもそも一度に取得する件数に200件とかの上限があって、201件目以降を取得したい場合はoffiset付きで取得してください、といった使われ方をするのが多いのではないでしょうか。

単純増加かつ昇順で検索する場合はデータの重複や抜けが起きにくいですが、単純増加かつ降順の場合や途中にデータが差し込まれる場合は前回取得した位置がズレるので、データの抜けや重複が発生する場合があります。

ページベースページネーション

GET /items?page=3&size=10

特徴

  • 利点:ユーザー視点ではページ単位が直感的。
  • 欠点:内部的にはoffset/limitと同じ課題(大きなページ数で遅くなる)。

オフセットベースと基本的に同じ発想ですが、指定方法が具体的な件数ではなくページという単位にすることで指定しやすくした方式でです。多くのサービスでは1ページあたりのサイズ(size)は指定せず、ページのみでアクセスさせるのではないかと思います。サービス側で設定しているページサイズをもとにデータを区切って何ページ目を取得するか、という形で指定することになります。

指定方法が異なるだけで、基本的な性質はオフセットベースと同じです。

カーソルベースページネーション

GET /items?after=eyJpZCI6MTIzfQ==&limit=10

特徴

  • 利点:安定して高速(インデックスが効く)。データが更新されても影響を受けにくい。
  • 欠点:実装がやや複雑。前のページに戻るのが難しい。

前述のオフセットベースとページベースでは、単純増加のキーで保存されているデータを降順で取得するとデータの抜けの心配があります。多少前後で同じデータが表示されたり、抜けがあってもそれほど問題にならない場合は、オフセットベースやページベースが簡単だと思いますが、特に抜けを許容できない場合はカーソルベースページネーションが有効な手立てとなります。

条件として検索キーが単純増加かつ途中に差し込まれることがないテーブルに対して指定した値(after)よりも前や後のデータをヒットさせるようにすることで重複や抜けがないようにデータを取得することができます。

いずれのパターンにおいても、テーブル設計やインデックス設計が重要となることに加え、取得されたデータの重複や抜けをどこまで対策するか許容するかの要件によって選択肢が変わってきます。

後述の検証では、カーソルベースページネーションをPostgreSQLのLIMIT句を使って実現できるかを検証してきます。

PostgreSQLのLIMIT句の挙動について

PostgreSQLのLIMIT句を使ってカーソルベースページネーションが有効に働くかを訊いたところ、以下のように回答がありました。

質問

以下のようなSQLを発行したときに、インデックスを使って条件に合致する10件のみをフェッチできますか?

SELECT * FROM histories
WHERE create_user_id = ? AND id < ?
ORDER BY id DESC
LIMIT 10;

前提条件

  • id:PK(= 自動で昇順インデックスがある)+ id DESC の降順インデックスも明示的に付与されている
  • create_user_id:昇順インデックスあり(例:CREATE INDEX ON histories (create_user_id))

回答


PostgreSQLは 複合インデックス がある場合に、以下のような最適化を実現できます:

CREATE INDEX idx_histories_userid_id_desc
ON histories (create_user_id, id DESC);

どうやら、個別のインデックスではダメで、WHERE句とLIMIT句の両方を満たす1つのインデックスがあれば、インデックスを効率的に使用して条件に合致する10件のみをフェッチできるようです。

ChatGPT からは PostgreSQL をデータベースとして使用している場合に、 LIMIT句を使った方法が可能であると回答をもらいました。ただし、生成AIは必ずしも正しい回答をするわけではないので、実際に試して検証することにします。

カーソルベースページネーションの検証

Docker composeでPostgreSQLを構築する

手早くPostgreSQLの環境を構築するために、Docker composeで作成しました。

services:
  db:
    image: postgres:17
    container_name: postgres17
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgres
    ports:
      - "5432:5432"
    volumes:
      - ./initdb:/docker-entrypoint-initdb.d:ro

すでに同じcontainer_nameを起動している場合は他の名前を指定してください。とりあえず起動確認だけしておきます。

$ docker compose up -d
[+] Running 2/2
 ✔ Network pagination-demo_default  Created
 ✔ Container postgres17             Started
$ psql -h localhost -U postgres -d postgres
Password for user postgres:
psql (17.4, server 17.2 (Debian 17.2-1.pgdg120+1))
Type "help" for help.

postgres=# \dt
Did not find any relations.
postgres=#
postgres-# \q

とりあえず問題なさそうです。

検証用のテーブルを作成する

今回は以下の要件を満たす、download_historiesテーブルを作成します。

  • PKはレコードの登録順でインクリメントされ、必ず時系列順になる(途中で割り込まれることがない)
  • ユーザーIDをもち、ユーザーIDで対象レコードを絞り込むことができる

以下のようなテーブルにしましたが、今回検証で使用するのはid列とuser_id列です。

CREATE TABLE download_histories (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id INTEGER        NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

こちらも念のため動作確認しておきます。

$ docker compose down -v
[+] Running 2/2
 ✔ Network pagination-demo_default  Created
 ✔ Container postgres17             Started
$ psql -h localhost -U postgres -d postgres
Password for user postgres:
psql (17.4, server 17.2 (Debian 17.2-1.pgdg120+1))
Type "help" for help.

postgres=# \dt
               List of relations
 Schema |        Name        | Type  |  Owner
--------+--------------------+-------+----------
 public | download_histories | table | postgres
(1 row)

postgres=# \d download_histories
                              Table "public.download_histories"
   Column   |           Type           | Collation | Nullable |           Default
------------+--------------------------+-----------+----------+------------------------------
 id         | bigint                   |           | not null | generated always as identity
 user_id    | integer                  |           | not null |
 created_at | timestamp with time zone |           | not null | now()
Indexes:
    "download_histories_pkey" PRIMARY KEY, btree (id)

postgres=# \q

問題なくテーブルが作成されていることを確認できました。

ページネーションに必要なインデックスを設定する

ページネーションを実現するための要件は以下のとおりです。

  • データはid列の降順(=created_at列の降順)で取得する
  • 指定したuser_idと一致すレコードを取得する

id列は単純増加のため、降順でid=100まで取得していれば次のデータは必ず100未満になることが保証されています。(取得するデータは最新のデータから過去の向けって取得する)

動作を検証するために以下の2種類のインデックスを試します。

  • PKに加え、id列の降順インデックスとuser_id列の昇順インデックスをそれぞれ追加する
  • user_id列の昇順+id列の降順の複合インデックスを追加する
-- 1. id の降順インデックス
CREATE INDEX idx_download_histories_id_desc
  ON download_histories (id DESC);

-- 2. user_id の昇順インデックス
CREATE INDEX idx_download_histories_user_id_asc
  ON download_histories (user_id ASC);

-- 3. user_id 昇順 + id 降順 の複合インデックス
CREATE INDEX idx_download_histories_user_id_id_desc
  ON download_histories (user_id ASC, id DESC);

これは必要になったタイミングで設定するようにします。

テストデータを用意するSQLを作成する

インデックスが十分に効いているかを検証したいので、それなりにデータ量が必要となります。

今回は以下の条件を満たすレコードを生成することにしました。

  • user_id1から100までの100名とする
  • 1つのuser_idに対して100,000件のレコードを登録する

登録するレコード数は10,000,000件になるため、1行ずつ登録するのは非効率なため、以下のSQLでまとめて登録するようにしています。

INSERT INTO download_histories (user_id)
SELECT gs_users.user_id
FROM generate_series(1, 100)   AS gs_users(user_id)
CROSS JOIN generate_series(1, 100000) AS gs_seq(seq);

これをinitdb配下に配置した初期データ投入用のSQLファイルに以下のように定義してコンテナを再起動します。

CREATE TABLE download_histories (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id INTEGER        NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

INSERT INTO download_histories (user_id)
SELECT gs_users.user_id
FROM generate_series(1, 100)   AS gs_users(user_id)
CROSS JOIN generate_series(1, 100000) AS gs_seq(seq);

ページネーションで発行する想定のSQLをexplain付きで実行すると、以下のようになりました。

postgres=# explain select * from download_histories where user_id = 56 and id < 12345 order by id desc limit 10;
                                                        QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.43..201.87 rows=10 width=20)
   ->  Index Scan Backward using download_histories_pkey on download_histories  (cost=0.43..363532.10 rows=18047 width=20)
         Index Cond: (id < 12345)
         Filter: (user_id = 56)
(4 rows)

体感では非常に高速でしたが、実行計画を見るとcostが高いことがわかります。少ない件数で試すとコストが下がっているので、唯一存在するPKによるインデックススキャンは使用しつつも登録されているデータ件数に依存したコストがかかっていることがわかります。

これでひととおりの準備が整いました。次に前述のインデックスを当てた時に上記のSQLのコストがどう変わるのかを確認していきたいと思います。

検証1:user_id列とid列に個別のインデックスを設定する

ChatGPT の回答では十分な効果が得られないと言われていた個別のインデックスを設定する方法を試してみます。具体的には、以下のINDEXを追加して前述のSELECT文の実行計画を確認します。

-- 1. id の降順インデックス
CREATE INDEX idx_download_histories_id_desc
  ON download_histories (id DESC);

-- 2. user_id の昇順インデックス
CREATE INDEX idx_download_histories_user_id_asc
  ON download_histories (user_id ASC);

使用する初期データ投入用のSQLファイルの全体は、以下になります。

CREATE TABLE download_histories (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id INTEGER        NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_download_histories_id_desc
  ON download_histories (id DESC);

CREATE INDEX idx_download_histories_user_id_asc
  ON download_histories (user_id ASC);

INSERT INTO download_histories (user_id)
SELECT gs_users.user_id
FROM generate_series(1, 100)   AS gs_users(user_id)
CROSS JOIN generate_series(1, 100000) AS gs_seq(seq);

同じクエリを実行したところ以下のようになり、追加したインデックスは使用していないことがわかります。

postgres=# explain select * from download_histories where user_id = 56 and id < 12345 order by id desc limit 10;
                                                        QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.43..201.87 rows=10 width=20)
   ->  Index Scan Backward using download_histories_pkey on download_histories  (cost=0.43..363532.10 rows=18047 width=20)
         Index Cond: (id < 12345)
         Filter: (user_id = 56)
(4 rows)

データの件数が少なくてPKのインデックスを逆順で使えば十分と判断された可能性もありますが、とりあえずそのまま検証を続けていきます。

検証2:user_id列とid列の複合インデックスを設定する

続いて、user_id列とid列の複合インデックスを使って同じクエリを試してみます。使用する初期データ投入用のSQLファイルの全体は以下のようになります。

CREATE TABLE download_histories (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id INTEGER        NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_download_histories_user_id_id_desc
  ON download_histories (user_id ASC, id DESC);

INSERT INTO download_histories (user_id)
SELECT gs_users.user_id
FROM generate_series(1, 100)   AS gs_users(user_id)
CROSS JOIN generate_series(1, 100000) AS gs_seq(seq);

同じクエリを実行したところ以下のようになり、追加した複合インデックスが使われていることがわかります。

postgres=# explain select * from download_histories where user_id = 56 and id < 12345 order by id desc limit 10;
                                                         QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.56..18.87 rows=10 width=20)
   ->  Index Scan using idx_download_histories_user_id_id_desc on download_histories  (cost=0.56..189.12 rows=103 width=20)
         Index Cond: ((user_id = 56) AND (id < 12345))
(3 rows)

コストが格段に下がっており、インデックスを使用したときのフェッチ件数も18047から103に下がっていることがわかります。

追加検証:データのバランスを変更したどうなるか?

検証1で追加したインデックスが使われなかった点についてもう少しだけ検証してみます。データ量を少し増やしみて、それでも追加したインデックスは使われないのかを検証します。

以下のようにデータ量を増やして再度検証してみます。

  • ユーザー数:100 → 1,000
  • 1ユーザーあたりのデータ件数:100,000 → 10, 000

ダウンロード履歴テーブルなので、1ユーザーのダウンロード回数を抑えつつ、利用者数を増やす形にしています。

INSERT INTO download_histories (user_id)
SELECT gs_users.user_id
FROM generate_series(1, 1000)   AS gs_users(user_id)
CROSS JOIN generate_series(1, 10000) AS gs_seq(seq);

実行するクエリはデータのバランスが変更されたことに合わせて、以下のようにしました。

explain select * from download_histories where user_id = 1234 and id < 12345 order by id desc limit 100;

上記の条件で、各パターンで実行計画を取得すると以下のようになりました。

追加インデックスなし

postgres=# explain select * from download_histories where user_id = 1234 and id < 12345 order by id desc limit 100;
                                                     QUERY PLAN
---------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.43..492.30 rows=13 width=20)
   ->  Index Scan Backward using download_histories_pkey on download_histories  (cost=0.43..492.30 rows=13 width=20)
         Index Cond: (id < 12345)
         Filter: (user_id = 1234)
(4 rows)

個別インデックスあり

postgres=# explain select * from download_histories where user_id = 1234 and id < 12345 order by id desc limit 100;
                                                        QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.43..2014.80 rows=100 width=20)
   ->  Index Scan Backward using download_histories_pkey on download_histories  (cost=0.43..363532.10 rows=18047 width=20)
         Index Cond: (id < 12345)
         Filter: (user_id = 1234)
(4 rows)

複合インデックスあり

postgres=# explain select * from download_histories where user_id = 1234 and id < 12345 order by id desc limit 100;
                                                           QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.56..355.46 rows=100 width=20)
   ->  Index Scan using idx_download_histories_user_id_id_desc on download_histories  (cost=0.56..64049.50 rows=18047 width=20)
         Index Cond: ((user_id = 1234) AND (id < 12345))
(3 rows)

サーバーサイドページネーションは必ずしも必要ないかもしれない

2回目の検証でも1ユーザーあたり10,000回のダウンロード履歴を想定しています。何をダウンロードするかにもよりますが、営業日に平均1日1回ダウンロードするとしても年間240回、10,000回ダウンロードするには40年以上かかる計算になります。

となると、サーバーサイドページネーションが必要となるかどうかは、データの増加量とレスポンスサイズによると考えられます。

適当な状況を想定して計算してみましょう。

システムによって異なりますが、一般的にはレスポンスボディのサイズが10MB以下に設定するとします。ヘッダーの計算はちょっと面倒なので、2MBバイトだとしてレスポンスボディのサイズは8MB以下になるようにするとしましょう。

1件のデータが仮に1KBだとすると、8,192行のデータがレスポンスとして返せる計算になります。通常の利用範囲でシステムの耐用年数使い続けてもこの行数を超えないのであれば、そもそもサーバーサイドページネーションは不要ということになります。毎日平均10行増えるとしても1年間3,650行増えるので、2年ちょっとの間はこのサーバーサイドページネーションが必要ないと言えます。

ダウンロードを行った履歴となるとダウンロードしたファイル名やダウンロード日時などを含むだけなので、多めに見積もって512バイトだとして、月多くても10回ほどしかダウンロードしないとすると、年間120行増えるだけなので、レスポンスボディの上限に到達するには136年もかかることになるため、コストをかけてまでサーバーサイドページネーションを実装する必要はない、という結論になります。

このことから、かなりのペースでデータが増加したり、ヒットするデータ量が膨大になるなどの前提がある場合はサーバーサイドページネーションを検討する価値がありますが、ユーザー自身の操作でデータが増えるだけだとサーバーサイドページネーションがなくてもシステム的には問題ないという場合もある点は重要な学びです。

まとめ

最初はChatGPTの回答を検証することが目的でしたが、最終的にはサーバーサイドページネーションが必要かどうかはデータ量の見積もりによって決まるという話になりました。

最初の疑問に立ち返ると、ChatGPTの回答どおりにWHERE句とLIMIT句の条件を組み合わせた複合インデックスを追加することで、性能が向上することは確認できました。ただし、圧倒的に優れているかと言われるとデータのバランス次第という感じだったので、実際に検索するテーブルに想定されるバランスのテストデータを登録して実際に実行されるクエリを使って検証することが重要だと思います。

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