Dev Study
← 解説「パスワードのハッシュ化 — bcrypt」に戻る

サンプルコードで身につける: パスワードのハッシュ化 — bcrypt

解説で学んだ概念を、5つの具体例で確認します。素朴な例から実務寄りの例へと進みます。

1なぜ平文保存が危険か

パスワードを平文のまま持つとどうなるかをコメントで整理した例です。DB が漏えいした瞬間に全員のパスワードがそのまま読めてしまう、という最悪の事態を避けるのがハッシュ化の出発点です。

// 悪い例: パスワードを平文のまま保存する
// users テーブル
//   id | email             | password
//   ---+-------------------+-----------
//    1 | alice@example.com | hunter2     ← 漏えいで即読める
//    2 | bob@example.com   | password123 ← 使い回しなら他サービスも危険
//
// 良い例: ハッシュ化した「元に戻せない文字列」を保存する
//   id | email             | password_hash
//   ---+-------------------+--------------------------------
//    1 | alice@example.com | $2b$10$N9qo8uLOickgx2ZMRZo...
//
// ハッシュは一方向: 保存値から元のパスワードは復元できない

2ハッシュ化と照合の最小形

bcrypt の hash() で保存用の値を作り、compare() で照合する基本2メソッドを示す例です。保存時はハッシュ化、ログイン時は平文に戻さず照合、という一方向の流れがパスワード管理の型です。

import * as bcrypt from "bcrypt";

// 登録時: 平文をハッシュ化して保存する
async function register(plain: string): Promise<string> {
  const hash = await bcrypt.hash(plain, 10); // 10 = saltRounds
  return hash; // この文字列を DB に保存する
}

// ログイン時: 入力と保存済みハッシュを照合する(復号はしない)
async function login(plain: string, stored: string): Promise<boolean> {
  return bcrypt.compare(plain, stored); // 一致すれば true
}

3ソルトが毎回違うことを再現する

bcrypt が同じパスワードでも毎回違うハッシュを生成する理由「ソルト」を、プレーン TypeScript で再現した例です。ランダムなソルトを混ぜるため保存値は毎回変わり、事前計算したレインボーテーブル攻撃が効かなくなります。

TypeScript

4MD5/SHA-256 を使ってはいけない理由

汎用ハッシュ(MD5/SHA-256)がパスワード保存に不向きな理由を整理した例です。これらは高速なのが取り柄ですが、その速さこそが総当たり攻撃を助けてしまうため、わざと遅い bcrypt や argon2 を使います。

// パスワード保存に汎用ハッシュ(MD5 / SHA-256)を使うのは誤り
//
// SHA-256: 1秒間に数十億回計算できる = 攻撃者も高速に総当たりできる
//   攻撃者の試行イメージ
//   sha256("aaaa") → 不一致
//   sha256("aaab") → 不一致  … 1秒で数十億通り試せてしまう
//
// bcrypt / argon2: あえて遅く・メモリを食うよう設計されている
//   saltRounds=10 なら1回の計算に数十ミリ秒かかる
//   → 総当たりが現実的でなくなる(これが狙い)
//
// 結論: パスワードは必ずパスワード専用のハッシュ関数を使う

5登録とログインをサービスにまとめる

ユーザー登録とパスワード検証を1つのサービスにまとめた実務寄りの例です。ハッシュ化と照合をこのサービスに閉じ込め、平文パスワードがここから外へ漏れないようにするのが設計の要点です。

import { Injectable, UnauthorizedException } from "@nestjs/common";
import * as bcrypt from "bcrypt";

type StoredUser = { id: number; email: string; passwordHash: string };

@Injectable()
export class AuthService {
  constructor(private readonly users: UsersRepository) {}

  // 登録: 平文は即ハッシュ化し、ハッシュだけを保存する
  async register(email: string, plain: string): Promise<void> {
    const passwordHash = await bcrypt.hash(plain, 10);
    await this.users.create({ email, passwordHash });
  }

  // 検証: ユーザーを引いて compare、失敗時は同じ 401 で返す
  async validateUser(email: string, plain: string): Promise<StoredUser> {
    const user = await this.users.findByEmail(email);
    // 「該当なし」も「パスワード不一致」も区別せず弾く(情報を漏らさない)
    if (!user || !(await bcrypt.compare(plain, user.passwordHash))) {
      throw new UnauthorizedException("メールまたはパスワードが違います");
    }
    return user;
  }
}