経緯 — なぜ .env.local に bcrypt ハッシュを置いたか
通常、ユーザーのパスワードハッシュは users テーブル等の DB に保存するのが一般的です。今回の .env.local に直接書く方式は DB 導入前の暫定的な仮実装 として選んだもので、後のフェーズで DB(users テーブル)に移行する予定でした。
そのため、もし最初から DB で実装していれば本問題は発生しません。「個人開発で段階的に作る過程で、DB 導入前の最小構成を環境変数で組んだ結果ハマった話」としてお読みください。
起きたこと
NextAuth.js v5 + bcryptjs で管理者ログインを実装中、.env.local に bcrypt ハッシュを保存したのに、何度試してもログインが通らなくなりました。ハッシュ値は目で見て正しい、メールアドレスも合っている、開発サーバーを再起動しても変わらない。
詰まった末に切り分けを進めた結果、原因は コードでも .env.local の書式でもなく、Next.js 自身の .env 読み込みの挙動の中にあった ことが分かりました。
本記事では症状、原因、対処を順に書きます。
検証環境
| 項目 | バージョン |
|---|---|
| Next.js | 16.2.5 (Turbopack) |
| NextAuth.js | 5.0.0-beta.31 |
| bcryptjs | 3.0.3 |
エラーと最初の見立て
ログイン送信時、開発サーバーのターミナルにはこれだけが出ます。
[auth][error] CredentialsSignin: Read more at https://errors.authjs.dev#credentialssignin
最初は「パスワードを間違えただけ」と思いハッシュを生成し直したのですが、何度再生成しても通らない。grep で .env.local を覗くと `ADMIN_PASSWORD_HASH="$2b$10$...`` で形式は問題なさそうに見えました。
ところがコードから process.env.ADMIN_PASSWORD_HASH を出力してみると、値が 空文字 になっていました。.env.local には確かに書いたはずなのに、process.env 経由では取得できない。原因は .env.local の読み込み段階にあったのです。
原因 — @next/env の $ 展開
Next.js は .env.local を @next/env 経由で読み込み、内部で dotenv-expand を使って値の中の $VAR 形式を展開します。bcrypt ハッシュ $2b$10$lLoWAAQJ... の場合、$2b・$10・$lLoWAAQJ... がそれぞれ未定義の変数として 空文字に置き換わる ため、最終的に値全体が壊れます。
ここで気をつけたいのが、Bash や zsh とは違って クォート種別を見ない 点です。シェルでは「シングルクォート内では $ はリテラル」が常識ですが、@next/env はシングルクォートで囲んでも展開を試みます。Laravel の .env(phpdotenv)など、シェル的に動くツールに慣れていると詰まりやすい挙動です。
公式ドキュメント(Environment Variables | Next.js)の「Default Environment Variables」セクションに「$ を値に含めたい場合は \$ でエスケープする」との注意書きがあります。
解決方法
値の中の各 $ を \$ に置き換えます。
# ❌ どれも壊れる
ADMIN_PASSWORD_HASH="$2b$10$lLoWAAQJwm1cxoYO9u3Mienq07V7HqVoOcIFimXddAa7ivMsCoy02"
ADMIN_PASSWORD_HASH='$2b$10$lLoWAAQJwm1cxoYO9u3Mienq07V7HqVoOcIFimXddAa7ivMsCoy02'
# ✅ 正しく読まれる
ADMIN_PASSWORD_HASH='\$2b\$10\$lLoWAAQJwm1cxoYO9u3Mienq07V7HqVoOcIFimXddAa7ivMsCoy02'
エスケープした値は読み込み時に \ が外れて $ 1 文字になるので、process.env.ADMIN_PASSWORD_HASH の中身は本来の bcrypt ハッシュそのものになります。コード側で \$ を意識する必要はありません。
開発サーバーを再起動して再ログインすると、hashLength: 60 で正しく読まれ、認証が通ります。
同じ場面に出会う条件
$ を含む値全般で同じ問題が起きます。
- bcrypt ハッシュ(本記事)
- PostgreSQL の DSN にパスワードとして
$が含まれる - SendGrid API キー(
SG.xxx.$xxx形式) - Stripe webhook secret(
whsec_$xxx)
上記いずれかを .env.local に書く時は、\$ エスケープを反射的に入れる癖をつけておくと再発を防げます。
まとめ
-
.env.localに$を含む値を書く時は、値の中の各$を\$でエスケープ する - シングルクォートで囲んでも防げない。
@next/envはクォート種別を見ずに$展開を試みる - 症状は「認証が通らない」など曖昧で気付きにくいので、コードから
process.envの値を直接確認するのが切り分けの近道 - bcrypt 以外でも
$を含む API キーで同じ対処が必要
検索でこの記事にたどり着いた方は、まず .env.local の $ を \$ に置き換えて開発サーバーを再起動してみてください。