YASD-TECH
YASD TECH
# Prisma

保存時に自動でハッシュ化・暗号化する(Client Extensions の query コンポーネント)

投稿日:2026/8/15

更新日:2026/8/15

ttitleImage

保存時に自動でハッシュ化・暗号化する(Client Extensions の query コンポーネント)

「パスワードは平文で保存したくない」「メールアドレスは DB 上で読めないようにしたい」というときに、
Prisma のどこにその処理を挟むかという話。@hashed のようなスキーマの機能は存在しないので、
$extends(Client Extensions)の query コンポーネントで書き込みを横取りするのが現在の正解。

結論

  • Prisma スキーマに「ハッシュ化して保存する」宣言的な機能はない。自分で処理を挟む。
  • 挟む場所は Client Extensions の query コンポーネントprisma.$extends({ query: ... }))。
    かつての prisma.$use()(middleware)は v6.14.0 で削除済みなので新規に使わない。
  • ハッシュ化(不可逆)と暗号化(可逆)は別物。ハッシュ化したカラムは元に戻せず where で普通に検索もできない。
    検索したいなら暗号化 + blind index(HMAC の固定ハッシュ列)をセットで持つ。
  • 拡張が起動するのはトップレベルのクエリだけ。モデル別にフックを書くとネストした書き込みが漏れる($queryRaw は素通り)。

まず決める:ハッシュ化か、暗号化か

ハッシュ化(bcrypt・argon2) 暗号化(AES-256-GCM)
元に戻せるか ❌ 戻せない ⭕️ 鍵があれば戻せる
主な用途 パスワード、照合だけできればいいもの メールアドレス・電話番号など、後で表示・送信するもの
同じ入力→同じ出力 ❌ 毎回変わる(ソルトが違う) ❌ 毎回変わる(IV が違う)
照合の仕方 bcrypt.compare(平文, ハッシュ) 復号して比較
where で検索 ❌ できない ❌ そのままでは不可 → blind index が必要
鍵の管理 不要 必要(漏れたら終わり・ローテーション設計も要る)

「戻す必要があるか」だけで決まる。パスワードは戻す必要がないので必ずハッシュ化。
メールアドレスを bcrypt で潰すと、送信も検索もできない使えないカラムになる。


素朴な実装とその限界

まずはサービス層で普通に書く形。これで足りるならこれでいい。

ts
// src/services/user.ts
import bcrypt from "bcryptjs";
import { prisma } from "../db";

export async function createUser(email: string, password: string) {
  return prisma.user.create({
    data: { email, password: await bcrypt.hash(password, 12) },
  });
}

限界は 1 つだけで、prisma.user.create() を直接呼ぶ抜け道が残ること。
管理画面の実装、シードスクリプト、バッチ、あとから入った人のコード。どれか 1 箇所でも
data: { password: "..." } を平文で渡すと、そのレコードだけ平文で入り、しかも気づかない。

「呼び忘れを型やレビューではなく仕組みで塞ぎたい」ときに拡張が効いてくる。


Client Extensions の query コンポーネントで自動化する

$extends新しいクライアントを返す(元のインスタンスは変わらない)。
query コンポーネントは { model, operation, args, query } を受け取り、args を書き換えて query(args) を呼ぶ。

flowchart TD
    App["アプリコード<br/>prisma.user.create({ data: { password: '平文' } })"]
    Ext["query 拡張<br/>create / update / upsert / createMany を横取り"]
    Client["Prisma Client"]
    DB[("PostgreSQL<br/>password カラムは $2b$... のハッシュ")]
    Raw["prisma.$queryRaw(拡張を通らない)"]

    App --> Ext
    Ext -->|"args.data.password を書き換えてから実行"| Client
    Client --> DB
    App -.->|"素通り。平文がそのまま入る"| Raw
    Raw --> DB

実装。Prisma.defineExtension を使うとファイルを分けても型が効く。

ts
// src/db/extensions/hash-password.ts
import { Prisma } from "@prisma/client";
import bcrypt from "bcryptjs";

const ROUNDS = 12;

// bcrypt のハッシュは $2a$ / $2b$ / $2y$ で始まる。二重ハッシュの保険。
const looksHashed = (value: string) => /^\$2[aby]\$\d{2}\$/.test(value);

const toHash = async (value: string) =>
  looksHashed(value) ? value : bcrypt.hash(value, ROUNDS);

export const hashPassword = Prisma.defineExtension((client) =>
  client.$extends({
    name: "hashPassword",
    query: {
      user: {
        async create({ args, query }) {
          if (typeof args.data.password === "string") {
            args.data.password = await toHash(args.data.password);
          }
          return query(args);
        },

        async update({ args, query }) {
          // update の値は "文字列" か { set: "文字列" } のどちらか
          const p = args.data.password;
          if (typeof p === "string") {
            args.data.password = await toHash(p);
          } else if (p && typeof p.set === "string") {
            args.data.password = { set: await toHash(p.set) };
          }
          return query(args);
        },

        async upsert({ args, query }) {
          // create と update の両方を持つので両方処理する
          if (typeof args.create.password === "string") {
            args.create.password = await toHash(args.create.password);
          }
          const p = args.update.password;
          if (typeof p === "string") {
            args.update.password = await toHash(p);
          } else if (p && typeof p.set === "string") {
            args.update.password = { set: await toHash(p.set) };
          }
          return query(args);
        },

        async createMany({ args, query }) {
          // createMany の data は配列にも単体にもなる
          const rows = Array.isArray(args.data) ? args.data : [args.data];
          args.data = await Promise.all(
            rows.map(async (row) => ({
              ...row,
              password: typeof row.password === "string"
                ? await toHash(row.password)
                : row.password,
            })),
          );
          return query(args);
        },
      },
    },
  }),
);

使う側。拡張を当てたクライアントだけを export するのが肝心。

ts
// src/db/index.ts
import { PrismaClient } from "@prisma/client";
import { hashPassword } from "./extensions/hash-password";

// 素の new PrismaClient() は外に出さない。出すと抜け道になる。
export const prisma = new PrismaClient().$extends(hashPassword);
ts
await prisma.user.create({
  data: { email: "aoi@example.com", password: "hunter2" },
});
// → DB には $2b$12$... が入る。呼び出し側は平文を渡すだけでよい

書き込み側の対になる読み取り側は、omit でハッシュ列を返さないようにしておく(Prisma 6.2 で GA)。

ts
export const prisma = new PrismaClient({
  omit: { user: { password: true } }, // 全クエリから除外
}).$extends(hashPassword);

// 照合するときだけ明示的に戻す
const user = await prisma.user.findUnique({
  where: { email },
  omit: { password: false },
});
await bcrypt.compare(input, user.password);

暗号化したい場合:blind index とセットで持つ

AES-GCM は IV がランダムなので、同じメールアドレスでも暗号文は毎回変わる。
つまり where: { email: "aoi@example.com" } が原理的に当たらない。
そこで検索用の固定ハッシュ列(blind index)を別カラムに持つ

prisma
model User {
  id        Int    @id @default(autoincrement())
  email     String                              // 暗号文を入れる
  emailHash String @unique @map("email_hash")   // HMAC-SHA256(検索・一意制約用)
  password  String
}
id email email_hash
1 v1:9Xk2...(毎回変わる) 4c1f...(同じ入力なら常に同じ)
2 v1:Qz7p... a90e...
ts
// src/db/crypto.ts
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto";

const KEY = Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, "base64");  // 32 bytes
const INDEX_KEY = Buffer.from(process.env.BLIND_INDEX_KEY!, "base64"); // 別の鍵にする

export function encrypt(plain: string) {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", KEY, iv);
  const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
  // "v1:" は鍵ローテーション時に世代を見分けるための目印
  return `v1:${Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64")}`;
}

export function decrypt(stored: string) {
  if (!stored.startsWith("v1:")) return stored; // 未暗号化の既存データを壊さない
  const raw = Buffer.from(stored.slice(3), "base64");
  const decipher = createDecipheriv("aes-256-gcm", KEY, raw.subarray(0, 12));
  decipher.setAuthTag(raw.subarray(12, 28));
  return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8");
}

// 正規化してからハッシュしないと "Aoi@..." と "aoi@..." が別人になる
export const blindIndex = (plain: string) =>
  createHmac("sha256", INDEX_KEY).update(plain.trim().toLowerCase()).digest("hex");

拡張側では「書き込み時に暗号化 + ハッシュ列を埋める」「検索条件の emailemailHash に差し替える」「結果を復号する」の 3 つをやる。

sequenceDiagram
    participant App as アプリコード
    participant Ext as query 拡張
    participant DB as PostgreSQL

    App->>Ext: findUnique({ where: { email: "aoi@example.com" } })
    Ext->>Ext: where を emailHash に差し替え
    Ext->>DB: SELECT * FROM users WHERE email_hash = '4c1f...'
    DB-->>Ext: email = "v1:9Xk2..."(暗号文)
    Ext->>Ext: decrypt して email を平文に戻す
    Ext-->>App: { email: "aoi@example.com" }
ts
export const encryptEmail = Prisma.defineExtension((client) =>
  client.$extends({
    name: "encryptEmail",
    query: {
      user: {
        async create({ args, query }) {
          if (typeof args.data.email === "string") {
            const plain = args.data.email;
            args.data.email = encrypt(plain);
            args.data.emailHash = blindIndex(plain);
          }
          const user = await query(args);
          return { ...user, email: decrypt(user.email) };
        },

        async findUnique({ args, query }) {
          // where: { email } を where: { emailHash } に差し替える
          if (typeof args.where.email === "string") {
            args.where = { emailHash: blindIndex(args.where.email) };
          }
          const user = await query(args);
          return user ? { ...user, email: decrypt(user.email) } : user;
        },

        async findMany({ args, query }) {
          const users = await query(args);
          return users.map((u) => ({ ...u, email: decrypt(u.email) }));
        },
      },
    },
  }),
);

blind index は完全一致にしか使えないcontains / startsWith での部分一致検索は諦めることになる。
「暗号化したいが部分一致もしたい」は素直には両立しないので、要件の段階で潰しておく。

自前で全部書くのが重いなら prisma-field-encryption がある。
スキーマのコメントに /// @encrypted と書いたフィールドを透過的に暗号化し、/// @encryption:hash(email) で blind index も面倒を見てくれる。

prisma
model User {
  email     String? @unique /// @encrypted
  emailHash String? @unique @map("email_hash") /// @encryption:hash(email)?saltEnv=EMAIL_SALT&outputEncoding=base64
}
ts
const prisma = new PrismaClient().$extends(
  fieldEncryptionExtension({ encryptionKey: env.DATABASE_ENCRYPTION_KEY }),
);

中身は $allModels$allOperations 1 本で、DMMF(スキーマのメタ情報)を見ながら args を再帰的に走査して
書き込み時に暗号化・読み取り時に復号している。実際に使うときに引っかかる点は 2 つ。

  • $extends の戻り値の型からモデル情報が落ちることがある(Issue #142)。
    as unknown as PrismaClient で元の型に戻して使うのが現実的な回避策。
  • 実行時に Prisma.dmmf を読ませたくない場合(起動時間・バンドルサイズ)は、
    ビルド時に DMMF を生成しておいて fieldEncryptionExtension({ dmmf }) に渡す。

復号を result コンポーネントでやろうとすると詰まる

$extends にはもう 1 つ result コンポーネントがあり、こちらのほうが宣言的に見える。

ts
result: {
  user: {
    emailPlain: {
      needs: { email: true },
      compute: (user) => decrypt(user.email),
    },
  },
}

ただし result計算フィールドを増やすためのもので、次の制約がある。

  • needs に書けるのは同じモデルのスカラーフィールドだけ(リレーションは不可)
  • 計算フィールドは where / orderBy / 集計に使えない
  • 元フィールドをクエリから外す(select で選ばない)と undefined になる

「読み取り時に既存フィールドの中身を差し替える」用途なら、素直に query 側で結果を書き換えるほうが素直。
resultemailPlain のような別名の派生フィールドを足す用途に留めるのが安全。


どこでやるか(3つの選択肢)

サービス層で都度 Client Extensions DB 側(pgcrypto など)
呼び忘れ 起きる 起きにくい 起きない
実装コスト 中〜高
raw クエリ 素通り 素通り カバーされる
鍵の置き場所 アプリ アプリ DB サーバー(DB が漏れると一緒に漏れがち)
向いている場面 対象が 1〜2 箇所 対象カラムが決まっていて全経路で強制したい 既存の SQL 資産が多い

「DB を盗まれても読めない」を目的にするなら、鍵は DB の外(アプリ・KMS)に置く。
DB 内で暗号化・復号する構成は、DB ごとダンプされた場合に守れないことがある。


ハマりどころ

ネストした書き込みでは「そのモデルのフック」が起動しない。
拡張が起動するのはトップレベルのクエリだけ。次の書き方で呼ばれるのは team.create のフックであって、
usercreate フックは呼ばれない。上の実装のようにモデル別にフックを書いていると、ここが漏れる。

ts
// ❌ user の create 拡張は起動しない(トップレベルの操作は team.create)
await prisma.team.create({
  data: {
    name: "デザイン",
    members: { create: [{ email: "aoi@example.com", password: "hunter2" }] },
  },
});

ただしネストしたデータを処理できないわけではない。起動した team.create のフックが受け取る args の中には
members.create の中身も入っているので、$allModels × $allOperations で受けて args のツリーを再帰的に走査し、
対象モデル・対象フィールドを見つけて書き換えればよい。

ts
query: {
  $allModels: {
    async $allOperations({ model, operation, args, query }) {
      // args を深さ優先で辿り、対象フィールドを書き換えてから query(args)
    },
  },
}

prisma-field-encryption はこの方式で、スキーマの DMMF を見ながら args を走査してネストした書き込みも処理している。
自前でやるのが重いなら withNestedOperations() を提供する
prisma-extension-nested-operations を挟む手もある。
「ネスト操作を専用のフックとして扱う」公式サポートは Issue #24525 で要望中の段階。

$queryRaw / $executeRaw は素通りする。
$queryRaw に対する拡張フックは書けるが、SQL 文字列の中身を解析してハッシュ化するのは非現実的。
「生 SQL で書き込むならハッシュ化は自分の責任」と割り切る。

update の値は文字列とは限らない。
args.data.password の型は string | { set: string }typeof p === "string" だけ見ていると
{ set: "平文" } を渡された経路が漏れる。上のコードで両方見ているのはこのため。

二重ハッシュ。
「更新時にハッシュ済みの値をもう一度ハッシュしてしまう」事故は、update を通る値がハッシュ済みのときに起きる。
プレフィックス判定は保険にはなるが、平文が偶然 $2b$12$ で始まると誤判定する。
本命の対策は「拡張を通る値は常に平文」という約束をコード側で守ること。

素のクライアントが残っていると意味がない。
$extends は元のインスタンスを変更しない。new PrismaClient() を別ファイルでも作っていたら、
そちらからは平文が入る。エントリポイントを 1 つに絞る。

既存データの移行。
ハッシュ化は不可逆なので、平文カラムを一括ハッシュ化する移行は 1 度きりで後戻りできない。
暗号化なら "v1:" のような世代プレフィックスを付けておき、読み取り時に「プレフィックスがなければ平文とみなす」ようにすると、
バッチでの段階移行と鍵ローテーションがやりやすい。

$use() は使わない。
prisma.$use()(middleware)は v4.16.0 で非推奨、v6.14.0 で削除された。
ネット上の記事はまだ $use 前提のものが多いので、コピペ元のバージョンを確認する。


参考

Index

  • 保存時に自動でハッシュ化・暗号化する(Client Extensions の query コンポーネント)
  • 結論
  • まず決める:ハッシュ化か、暗号化か
  • 素朴な実装とその限界
  • Client Extensions の query コンポーネントで自動化する
  • 暗号化したい場合:blind index とセットで持つ
  • 復号を result コンポーネントでやろうとすると詰まる
  • どこでやるか(3つの選択肢)
  • ハマりどころ
  • 参考