パスワードリセットの「再設定リンク」を安全に作る:トークン設計と落とし穴
パスワード再設定リンクをハッシュ保存・短い有効期限・1回限り・全セッション無効化で安全に実装する手順。ユーザー列挙を防ぐ応答まで、写して動くコードと失敗談つきで解説。
「パスワードをお忘れですか?」のリンク、あれを軽く見ていた時期がありました。
僕が初めて作ったリセット機能は、トークンをそのままDBに保存して、有効期限もなし、何度でも使える代物でした。動いたので満足していたんです。あとでセキュリティに詳しい知人にコードを見せたら、開口一番「これ、DBが一回でも漏れたら全アカウント乗っ取れるよ」。背筋が凍りました。
考えてみれば当然です。リセットリンクは、パスワードを知らなくてもアカウントに入れる「裏口の合鍵」。ログイン画面をどれだけ固めても、この合鍵がザルなら全部台無しになります。しかも厄介なことに、リセット機能は見た目が地味なので、レビューでもさらっと流されがち。事故が起きてから「そういえばあそこ雑だったな」と気づくパターンが本当に多い。
この記事では、その合鍵——リセットトークンを安全に作る話に絞ります。推測できない値の作り方、ハッシュで保存する理由、短い有効期限、1回限りの使い切り、そして再設定が終わったあとに古いセッションを全部落とす処理。写して動くコードと、僕がやらかした失敗もまとめて置いておきます。
この記事の要点
- リセットトークンは
crypto.randomBytes(32)で作り、DBにはSHA-256ハッシュだけを保存する。生のトークンはメールにしか載せない。 - 有効期限は30分、使えるのは1回だけ。DBトランザクション内で
usedAtを埋めて二重使用を防ぐ。 - 「そのメールは登録されていません」は禁句。存在しても存在しなくても同じ応答・近い応答時間を返してユーザー列挙を防ぐ。
- 再設定が成功したら既存セッションを全部無効化し、自動ログインはしない。盗まれた端末のセッションを残さない。
- リセットページには
noindexとReferrer-Policy: no-referrerを付け、URLのトークンが検索や外部サイトへ漏れるのを防ぐ。
認証全体の組み方は Web認証の実装(セッション・bcrypt・Cookie)、メールの確実な送り方は Resendで作るトランザクションメール実装 に分けて書いています。この記事は「リセットトークンそのもの」に集中します。
なぜトークン設計だけ別格に難しいのか
ログイン処理は、入力したパスワードが合っているかを照合するだけ。シンプルです。
リセットは違います。パスワードを知らない人に、一時的にアカウントへの道を開く。つまり「本人かどうか」をパスワード以外の手段で担保しないといけない。その担保が、メールアドレスに届く一時的な合鍵=トークンです。
ここで多くの実装がつまずくのは、トークンを「ただのランダム文字列」だと思ってしまうから。実際には、トークンには満たすべき性質が4つあります。
- 推測できない:総当たりで当てられない長さとランダム性が要る。
- 盗まれても短時間で無効になる:メールは転送されるし履歴にも残る。
- 一度使ったら二度と使えない:転送先の誰かに先回りされない。
- 漏れても被害を限定できる:DBが漏れても、そのままログインに使えない形にしておく。
この4つを全部満たして初めて、リセット機能は「裏口」ではなく「安全な勝手口」になります。1つでも欠けると穴になる。だからこそ、リセットトークンは認証の中でも独立して設計する価値があるんです。
まず設計判断を表で固める
コードを書く前に、迷いどころを先に潰します。僕が実装レビューで毎回見る観点を、採用する方針と理由つきで並べました。OWASPの Forgot Password Cheat Sheet をベースにしています。
| 観点 | 採用する方針 | 理由 |
|---|---|---|
| ユーザー存在確認 | 存在しても、しなくても同じ応答 | メールアドレス列挙を防ぐ |
| トークン生成 | crypto.randomBytes(32).toString("base64url") | 推測できないランダム値にする |
| 保存方法 | 生のトークンではなくSHA-256ハッシュを保存 | DB漏えい時にURLトークンを使わせない |
| 有効期限 | 30分 | メール遅延と漏えいリスクのバランス |
| 使用回数 | 1回だけ | 転送・履歴・ログ経由の再利用を防ぐ |
| 同時使用 | トランザクション内で usedAt を埋める | 二重リクエストの先回りを防ぐ |
| 応答時間 | 存在有無で差が出ないよう下限を揃える | タイミングからの推測を防ぐ |
| メール本文 | パスワードは絶対に書かない | メールは安全な保管場所ではない |
| リセット画面 | noindex と Referrer-Policy: no-referrer | 検索登録と外部へのトークン漏れを防ぐ |
| 再設定後 | 既存セッションを無効化し自動ログインしない | 乗っ取られたセッションを残さない |
| MFA | リセットでMFAは解除しない | MFA再設定は別の本人確認フローへ |
この表を最初に作っておくと、コードを書くときも、あとでレビューするときも「方針どおりか」を機械的に照合できます。仕様が頭の中にあるだけだと、忙しい日に必ず1つ漏らします。
トークン生成と保存:合鍵の作り方
ここが心臓部です。順番はこう。crypto で32バイトのランダム値を作る → それを base64url で文字列にしてメールのURLに載せる → 同じ値のSHA-256ハッシュを計算してDBにはハッシュだけ保存する。
なぜ生のトークンを保存しないのか。理由は1つ、DBが漏れたときのためです。生のトークンが保存されていれば、それをURLに貼るだけで他人になりすませる。でもハッシュしか保存していなければ、漏れたハッシュからは元のトークンを復元できないので、ログインには使えません。パスワードを平文で保存しないのと、まったく同じ理屈です。
下のサービス層は、そのまま小さなNext.js + Prismaアプリにコピーして動く粒度にしてあります。依存は @prisma/client、bcryptjs、zod の3つ。メール送信は Resend を例にしていますが、RESEND_API_KEY がないローカルではコンソールにURLを出すだけにしているので、メールを設定しなくても流れは試せます。
// src/lib/password-reset.ts
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const RESET_TTL_MINUTES = 30; // 有効期限は短く
const TOKEN_BYTES = 32; // 推測されない長さ
const MIN_RESPONSE_MS = 450; // 応答時間の差で存在を推測させない
const GENERIC_MESSAGE = {
message: "If an account exists for that email, a password reset link has been sent.",
};
// 生のトークンはメールへ、ハッシュだけをDBへ。これが漏えい対策の肝。
function hashToken(token: string) {
return crypto.createHash("sha256").update(token, "utf8").digest("hex");
}
function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
// メール内リンクのドメインは固定。Hostヘッダーから組み立てない。
function appOrigin() {
const origin = process.env.APP_ORIGIN;
if (!origin || !origin.startsWith("https://")) {
throw new Error("APP_ORIGIN must be an HTTPS origin such as https://example.com");
}
return origin.replace(/\/$/, "");
}
// 存在するユーザーだけ処理が重くならないよう、応答時間に下限を設ける。
async function padResponse(startedAt: number) {
const elapsed = Date.now() - startedAt;
if (elapsed < MIN_RESPONSE_MS) {
await new Promise((r) => setTimeout(r, MIN_RESPONSE_MS - elapsed));
}
}
async function sendResetEmail(to: string, resetUrl: string) {
// メール本文にパスワードは絶対に書かない。書くのは期限付きの再設定リンクだけ。
const html = `
<div style="font-family:Arial,sans-serif;line-height:1.6">
<h1 style="font-size:20px">Reset your password</h1>
<p>Use the button below to set a new password. This link expires in ${RESET_TTL_MINUTES} minutes.</p>
<p><a href="${resetUrl}" style="background:#2563eb;color:#fff;padding:12px 18px;border-radius:6px;text-decoration:none">Reset password</a></p>
<p>If you did not request this, ignore this email. Your password has not changed.</p>
</div>`;
if (!process.env.RESEND_API_KEY) {
// ローカルではメールを送らず、URLをログに出すだけ。
console.info("Reset email skipped in local dev", { to, resetUrl });
return;
}
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: process.env.MAIL_FROM ?? "[email protected]",
to,
subject: "Reset your password",
html,
}),
});
if (!res.ok) throw new Error(`Failed to send reset email: ${res.status}`);
}
export async function requestPasswordReset(input: {
email: string;
ip?: string;
userAgent?: string;
}) {
const startedAt = Date.now();
const email = normalizeEmail(input.email);
// ユーザーが見つからなくても、ここで早期returnしない。応答を揃える。
const user = await prisma.user.findUnique({
where: { email },
select: { id: true, email: true },
});
if (user) {
const token = crypto.randomBytes(TOKEN_BYTES).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + RESET_TTL_MINUTES * 60 * 1000);
await prisma.passwordResetToken.create({
data: {
userId: user.id,
tokenHash, // 生のトークンは保存しない
expiresAt,
requestedIp: input.ip ?? "unknown",
requestedUserAgent: input.userAgent?.slice(0, 300),
},
});
await prisma.auditLog.create({
data: {
userId: user.id,
event: "password_reset_requested",
ip: input.ip,
userAgent: input.userAgent?.slice(0, 300),
},
});
const resetUrl = `${appOrigin()}/reset-password?token=${encodeURIComponent(token)}`;
await sendResetEmail(user.email, resetUrl);
}
await padResponse(startedAt); // 存在有無で時間差を作らない
return GENERIC_MESSAGE; // どちらでも同じ文言を返す
}
export async function resetPassword(input: {
token: string;
newPassword: string;
ip?: string;
userAgent?: string;
}) {
if (input.newPassword.length < 12 || input.newPassword.length > 128) {
throw new Error("invalid_password");
}
const tokenHash = hashToken(input.token); // 受け取ったトークンも同じ方式でハッシュ化して照合
const now = new Date();
const passwordHash = await bcrypt.hash(input.newPassword, 12);
return prisma.$transaction(async (tx) => {
// 期限内・未使用のトークンだけを探す
const reset = await tx.passwordResetToken.findFirst({
where: { tokenHash, expiresAt: { gt: now }, usedAt: null },
select: { id: true, userId: true },
});
if (!reset) throw new Error("invalid_or_expired_token");
// usedAt:null の行だけ更新。二重リクエストでも1件しか通らない。
const claimed = await tx.passwordResetToken.updateMany({
where: { id: reset.id, usedAt: null },
data: { usedAt: now },
});
if (claimed.count !== 1) throw new Error("invalid_or_expired_token");
await tx.user.update({
where: { id: reset.userId },
data: { passwordHash, passwordChangedAt: now },
});
// 再設定したら、古いセッションを全部落とす
await tx.session.updateMany({
where: { userId: reset.userId, revokedAt: null },
data: { revokedAt: now },
});
await tx.auditLog.create({
data: {
userId: reset.userId,
event: "password_reset_completed",
ip: input.ip,
userAgent: input.userAgent?.slice(0, 300),
metadata: { sessionsRevoked: true },
},
});
return { message: "Password updated. Please sign in again." };
});
}
このコードが前提にするPrismaモデルはこれだけです。既存アプリに User や Session があるなら名前を合わせて取り込んでください。
// prisma/schema.prisma
model PasswordResetToken {
id String @id @default(cuid())
userId String
tokenHash String @unique // 生トークンではなくハッシュに一意制約
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
requestedIp String?
requestedUserAgent String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, expiresAt])
}
ポイントは tokenHash に @unique を付けること。そして生のトークン・送信したパスワード・メール本文は、監査ログにも一切残さないこと。残すのはイベント名とIP、User-Agentまでです。
「登録されていません」と返してはいけない理由
トークンを完璧に作っても、リクエスト受付の応答で1行ミスると全部漏れます。
いちばんやりがちなのが、存在しないメールに「そのメールアドレスは登録されていません」と親切に返すこと。攻撃者から見ると、これは宝の地図です。メールアドレスのリストを片っ端から投げて、エラーが返らなかったものだけ拾えば、「このサービスを使っている人の一覧」が手に入る。これをユーザー列挙(user enumeration)と呼びます。
だから応答はこう統一します。存在しても、存在しなくても、まったく同じ文言・同じHTTPステータスを返す。上のコードで GENERIC_MESSAGE を必ず返しているのはこのためです。
// src/app/api/auth/password-reset/request/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { requestPasswordReset } from "@/lib/password-reset";
const bodySchema = z.object({ email: z.string().email().max(320) });
// 入力が壊れていても、存在判定に使える差を返さない。常にこの応答。
const generic = {
message: "If an account exists for that email, a password reset link has been sent.",
};
function clientIp(req: Request) {
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
}
export async function POST(req: Request) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json(generic, { status: 202 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(generic, { status: 202 }); // 形式エラーでも同じ応答
}
await requestPasswordReset({
email: parsed.data.email,
ip: clientIp(req),
userAgent: req.headers.get("user-agent") ?? undefined,
});
return NextResponse.json(generic, { status: 202 });
}
見落としがちなのが応答時間です。文言を揃えても、存在するユーザーだけメール送信やDB書き込みで処理が重くなると、レスポンスが返るまでの時間に差が出る。攻撃者はその「コンマ数秒の遅さ」だけで存在を推測できます。サービス層で応答時間に下限(MIN_RESPONSE_MS)を設けているのは、この時間差を消すためです。文言とタイミング、両方を揃えて初めてユーザー列挙対策は完成します。
リセットページでトークンを漏らさない
URL方式では、トークンがブラウザのアドレスバーに入ります。ここに落とし穴がもう1つ。
リセットページに広告タグや外部の解析タグを置いていると、利用者が別サイトへ遷移した瞬間、RefererヘッダーでURLごと外部に送られることがあります。クエリにトークンが入っているので、トークンも一緒に流出する。実際に過去、大手サービスでこの経路の漏えいが報告されています。
防ぎ方はシンプルで、リセットページだけ Referrer-Policy: no-referrer と noindex を二重に付ける。Referrer-Policy は「どこから来たかを次のサイトへどこまで伝えるか」のルールで、no-referrer にすればURLは外へ出ません。noindex は検索エンジンにこのページを登録させない指定です。
// next.config.mjs
const nextConfig = {
async headers() {
return [
{
source: "/reset-password",
headers: [
{ key: "Referrer-Policy", value: "no-referrer" }, // URLを外部に渡さない
{ key: "X-Robots-Tag", value: "noindex, nofollow" }, // 検索に載せない
{ key: "Cache-Control", value: "no-store" }, // 共有端末でキャッシュさせない
],
},
];
},
};
export default nextConfig;
ヘッダーだけでなく、ページ側のメタデータでも robots と referrer を指定しておくと、設定ミスに気づきやすくなります。実務では二重に守るのが扱いやすい、というのが僕の結論です。
僕がやらかした失敗3つ
正直に書きます。リセット機能まわりは、まさに地雷原でした。
ひとつ目は、冒頭で書いたトークン平文保存。当時は「ハッシュ化はパスワードだけの話」だと思い込んでいました。でもリセットトークンは、それ単体でアカウントに入れる鍵です。パスワードと同じ重さで守らないといけない。createHash("sha256") を1行挟むだけの話なのに、これを知らずに何ヶ月か運用していたのは今でもヒヤッとします。
ふたつ目は、再設定後にセッションを残したこと。パスワードを変えたのに、攻撃者が乗っ取り済みの古いセッションはそのまま生きていた。つまり「パスワードを変えれば安全」という利用者の期待が裏切られる状態でした。session.updateMany で revokedAt を一括で埋めるようにしてから、ようやく「リセット=締め出し」が成立しました。
みっつ目は、HostヘッダーからリセットURLを組み立てたこと。req.headers.host を使ってリンクを作っていたら、細工したHostでリクエストされたとき、メール内のリンクが攻撃者のドメインになりうる、と指摘されました。利用者がそのリンクを踏めばトークンを攻撃者に渡してしまう。今は APP_ORIGIN という固定のHTTPSオリジンを環境変数で持ち、レビュー対象にしています。環境変数の扱いは Claude Code環境変数管理 にまとめました。
レビューと動作確認のチェックリスト
自分でもAIに任せても、最後はこのリストで差分を見ます。コードを書くより、ここを機械的に潰すほうが事故が減ります。
- リクエストAPIの応答が、存在するメールと存在しないメールで同じ文言・同じステータスか。
- 存在しないメールだけ明らかに速く返っていないか(応答時間の差)。
token、password、resetUrlをログに出していないか。- DBに保存しているのが生トークンではなく
tokenHashか。 expiresAtとusedAtの両方で、期限切れと使用済みを弾いているか。updateManyなどで、同時リクエスト時の二重使用を防いでいるか。- 再設定成功後に
Sessionを無効化し、自動ログインしていないか。 Referrer-Policyとnoindexがリセットページに付いているか。- リセットだけでMFAを解除・再登録していないか。
MFAについて一言。パスワードを取り戻せる人が、同じリンクでMFAも外せるなら、MFAの意味がほぼ消えます。MFA再設定は本人確認・通知・監査ログを持つ別フローに分けてください。設計は 2要素認証の実装(リカバリーコードと復旧) とセットで考える領域です。
確認はコマンドにやらせます。同じリクエストAPIを存在するメールと存在しないメールで叩いて、ステータスも本文も一致することを目で見ます。
npm install @prisma/client bcryptjs zod
npx prisma migrate dev --name add_password_reset
# 存在しないメール。下の本物と同じ応答・近い時間になるはず。
curl -i -X POST http://localhost:3000/api/auth/password-reset/request \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'
# 存在するメール
curl -i -X POST http://localhost:3000/api/auth/password-reset/request \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'
# 同じトークンで2回。1回目だけ成功し、2回目は弾かれることを確認。
curl -i -X POST http://localhost:3000/api/auth/password-reset/confirm \
-H "Content-Type: application/json" \
-d '{"token":"PASTE_TOKEN","password":"new-long-password-123","confirmPassword":"new-long-password-123"}'
よくある質問
Q. トークンはJWTで作ってもいいですか? おすすめしません。JWTは内容を自己完結で持つので「サーバー側で1回使ったら無効にする」「即座に失効させる」が苦手です。リセットトークンは使い捨てが命なので、ランダム値+DBで使用済みフラグを管理するほうが素直で安全です。
Q. 有効期限は30分で短すぎませんか? 利用者がメールを開いて再設定するには十分な長さです。逆にこれより長いと、転送やブラウザ履歴、スクリーンショット経由で漏れたトークンの有効時間が伸びます。迷ったら短い側に倒してください。期限切れは再リクエストすれば済みます。
Q. なぜハッシュにソルトを付けないのですか? パスワードと違い、リセットトークンは32バイトのランダム値で、それ自体が十分に長く一意です。総当たりが現実的に不可能なので、SHA-256のような高速ハッシュで十分。bcryptのような遅いハッシュは照合が無駄に重くなるだけで、ここでは使いません。
Q. rate limitは必須ですか?
本番では必須です。IP単位とメール単位の両方で回数を絞らないと、特定アドレスへ大量のリセットメールを送りつける嫌がらせや、トークンの総当たりを許します。共有ストア(RedisやUpstash)に回数を持たせ、メール送信より前に制限してください。記事中の Map は単一プロセスの検証用で、複数インスタンスでは共有されない点に注意です。
Q. 再設定が成功したら、そのままログインさせたほうが親切では? 気持ちはわかりますが、僕はやめました。自動ログインにすると、乗っ取り済みの古いセッションと新しいセッションが混在して扱いが曖昧になります。再設定後は全セッションを落として通常のログイン画面へ戻すほうが、利用者の「変えたら安全」という期待にも合うし、コードもレビューしやすくなります。
実際に試した結果
冒頭の「DBが漏れたら全アカウント乗っ取れる」を指摘されて以来、僕がリセット機能で最初に確認するのは1点だけになりました。DBを覗いて、生のトークンが残っていないか。tokenHash しか入っていなければ、最悪DBが漏れても、そのままログインに使われることはありません。ここが守れているだけで、夜の安心感がまるで違います。
そのうえで、毎回手で確かめる4点があります。存在するメールと存在しないメールでレスポンス本文・ステータス・応答時間が揃っていること。DBに平文トークンが残っていないこと。期限切れと使用済みのトークンが確実に弾かれること。再設定後に古いセッションが無効化されていること。この4点を手で通してから、自動テストに落とし込む——遠回りに見えて、これが結局いちばん事故らない順番でした。
リセット機能は地味です。でも、ここが安全かどうかで、ログイン画面の堅さが全部生きるか台無しになるかが決まります。まずは生トークンを保存していないか、自分のコードを今すぐ覗いてみてください。
認証まわりをまとめて固めたい人は Web認証の実装ガイド と 2要素認証の実装 も合わせてどうぞ。手を動かしながら相談したい場合は 研修・実装相談 でも受け付けています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。