投稿日:2026/8/15
更新日:2026/8/15

「パスワードは平文で保存したくない」「メールアドレスは DB 上で読めないようにしたい」というときに、
Prisma のどこにその処理を挟むかという話。@hashed のようなスキーマの機能は存在しないので、$extends(Client Extensions)の query コンポーネントで書き込みを横取りするのが現在の正解。
query コンポーネント(prisma.$extends({ query: ... }))。prisma.$use()(middleware)は v6.14.0 で削除済みなので新規に使わない。where で普通に検索もできない。$queryRaw は素通り)。| ハッシュ化(bcrypt・argon2) | 暗号化(AES-256-GCM) | |
|---|---|---|
| 元に戻せるか | ❌ 戻せない | ⭕️ 鍵があれば戻せる |
| 主な用途 | パスワード、照合だけできればいいもの | メールアドレス・電話番号など、後で表示・送信するもの |
| 同じ入力→同じ出力 | ❌ 毎回変わる(ソルトが違う) | ❌ 毎回変わる(IV が違う) |
| 照合の仕方 | bcrypt.compare(平文, ハッシュ) |
復号して比較 |
where で検索 |
❌ できない | ❌ そのままでは不可 → blind index が必要 |
| 鍵の管理 | 不要 | 必要(漏れたら終わり・ローテーション設計も要る) |
「戻す必要があるか」だけで決まる。パスワードは戻す必要がないので必ずハッシュ化。
メールアドレスを bcrypt で潰すと、送信も検索もできない使えないカラムになる。
まずはサービス層で普通に書く形。これで足りるならこれでいい。
// 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: "..." } を平文で渡すと、そのレコードだけ平文で入り、しかも気づかない。
「呼び忘れを型やレビューではなく仕組みで塞ぎたい」ときに拡張が効いてくる。
$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 を使うとファイルを分けても型が効く。
// 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 するのが肝心。
// src/db/index.ts
import { PrismaClient } from "@prisma/client";
import { hashPassword } from "./extensions/hash-password";
// 素の new PrismaClient() は外に出さない。出すと抜け道になる。
export const prisma = new PrismaClient().$extends(hashPassword);
await prisma.user.create({
data: { email: "aoi@example.com", password: "hunter2" },
});
// → DB には $2b$12$... が入る。呼び出し側は平文を渡すだけでよい
書き込み側の対になる読み取り側は、omit でハッシュ列を返さないようにしておく(Prisma 6.2 で GA)。
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);
AES-GCM は IV がランダムなので、同じメールアドレスでも暗号文は毎回変わる。
つまり where: { email: "aoi@example.com" } が原理的に当たらない。
そこで検索用の固定ハッシュ列(blind index)を別カラムに持つ。
model User {
id Int @id @default(autoincrement())
email String // 暗号文を入れる
emailHash String @unique @map("email_hash") // HMAC-SHA256(検索・一意制約用)
password String
}
| id | email_hash | |
|---|---|---|
| 1 | v1:9Xk2...(毎回変わる) |
4c1f...(同じ入力なら常に同じ) |
| 2 | v1:Qz7p... |
a90e... |
// 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");
拡張側では「書き込み時に暗号化 + ハッシュ列を埋める」「検索条件の email を emailHash に差し替える」「結果を復号する」の 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" }
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 も面倒を見てくれる。
model User {
email String? @unique /// @encrypted
emailHash String? @unique @map("email_hash") /// @encryption:hash(email)?saltEnv=EMAIL_SALT&outputEncoding=base64
}
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 を読ませたくない場合(起動時間・バンドルサイズ)は、fieldEncryptionExtension({ dmmf }) に渡す。$extends にはもう 1 つ result コンポーネントがあり、こちらのほうが宣言的に見える。
result: {
user: {
emailPlain: {
needs: { email: true },
compute: (user) => decrypt(user.email),
},
},
}
ただし result は計算フィールドを増やすためのもので、次の制約がある。
needs に書けるのは同じモデルのスカラーフィールドだけ(リレーションは不可)where / orderBy / 集計に使えないselect で選ばない)と undefined になる「読み取り時に既存フィールドの中身を差し替える」用途なら、素直に query 側で結果を書き換えるほうが素直。result は emailPlain のような別名の派生フィールドを足す用途に留めるのが安全。
| サービス層で都度 | Client Extensions | DB 側(pgcrypto など) | |
|---|---|---|---|
| 呼び忘れ | 起きる | 起きにくい | 起きない |
| 実装コスト | 低 | 中 | 中〜高 |
| raw クエリ | 素通り | 素通り | カバーされる |
| 鍵の置き場所 | アプリ | アプリ | DB サーバー(DB が漏れると一緒に漏れがち) |
| 向いている場面 | 対象が 1〜2 箇所 | 対象カラムが決まっていて全経路で強制したい | 既存の SQL 資産が多い |
「DB を盗まれても読めない」を目的にするなら、鍵は DB の外(アプリ・KMS)に置く。
DB 内で暗号化・復号する構成は、DB ごとダンプされた場合に守れないことがある。
ネストした書き込みでは「そのモデルのフック」が起動しない。
拡張が起動するのはトップレベルのクエリだけ。次の書き方で呼ばれるのは team.create のフックであって、user の create フックは呼ばれない。上の実装のようにモデル別にフックを書いていると、ここが漏れる。
// ❌ 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 のツリーを再帰的に走査し、
対象モデル・対象フィールドを見つけて書き換えればよい。
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 前提のものが多いので、コピペ元のバージョンを確認する。