1. なぜプレーンテキスト保存は絶対NGなのか?ハッシュ化と暗号化の根本的違い

酒本先輩!
今回からいよいよ認証編ですね!
これまでは誰でもカート操作ができる仕様でしたが、ついにユーザー認証を実装するんですね!
ユーザーのパスワードって、データベースにそのまま保存しちゃいけないというのは知っています!
でも、暗号化して保存するのとハッシュ化するのって、何が違うんですか?

とっても重要で基礎的なポイントね!
結論から言うと、パスワードの保存に『暗号化(復号可能な変換)』を使っては絶対ダメで、必ず『一方向ハッシュ化(復号不可能な変換)』を使わなければならないわ。
- 暗号化(可逆): 鍵(Secret Key)を使ってデータを復号可能。万が一サーバーや鍵が漏洩した場合、元のパスワードがそのまま暴露されてしまう。
- ハッシュ化(不可逆): 元の文字列から一方向のハッシュ値を生成。元に戻す方法は存在しない。認証時は『入力されたパスワードを再度ハッシュ化して比較』する。
【パスワード検証の仕組み(不可逆処理)】
1. ユーザー入力: "mySuperSecret123"
│
▼ (ハッシュ関数)
2. 計算されたハッシュ値: $2b$10$e8pX...
│
▼ (一致確認)
3. DBに保存されたハッシュ値: $2b$10$e8pX...

さらに、単なるハッシュ化(SHA-256など)だと、レインボーテーブル(事前計算されたハッシュ値の辞書)や GPU を使った超高速な総当たり攻撃に耐えられないの。
そこで必要になるのが 『ソルト(Salt:ランダムな塩)』 の付与と 『ストレッチング(計算負荷を高める繰り返し処理)』 よ!
2. 実務のデファクトアルゴリズム比較:bcrypt vs Argon2

Node.js の認証開発でよく使われる安全なアルゴリズムが bcrypt と Argon2 よ。
それぞれの特徴を比較してみましょう。
- bcrypt
- 特徴・歴史:長年使われており、圧倒的な実績とライブラリの安定性がある
- 防御対象:GPU による並列総当たり攻撃
- パラメータ調整:cost factor(計算回数)のみ
- 実務での採用度:最も一般的で導入が容易
- 注意点:入力の先頭 72 バイトまでしかハッシュに使われない(それ以降の文字は無視される)
- Argon2(Argon2id)
- 特徴・歴史:Password Hashing Competition(PHC)の優勝アルゴリズム。現在最も推奨される最新規格
- 防御対象:GPU / ASIC(専用ハードウェア)やサイドチャネル攻撃
- パラメータ調整:memoryCost(メモリ使用量)、timeCost(時間)、parallelism(並列度)
- 実務での採用度:セキュリティ要件が高い新規プロダクトで積極採用
講義での選択:
今回は環境依存のエラーが少なく、Node.js エコシステムで最も広く使われている bcrypt を使用して実装を学んでいきます。
bcrypt の 72 バイト制限は、後ほど Zod のバリデーションで入力の上限として弾きます。
3. ステートフル認証の王道:「セッション認証」の仕組みと Cookie のセキュリティ属性

パスワードを安全に保存できたら、次はログイン状態をどう保持するかね。ウェブアプリの認証には大きく分けて 『セッション認証』 と 『JWT認証(第17回で解説)』 があるわ。
今回はサーバー側で状態を保持する ステートフルな『セッション認証』 を学ぶわよ。
【セッション認証の処理フロー】
1. クライアント - [ ID / Password ] -> サーバー(パスワード検証)
2. サーバー - [ セッションID発行 ] -> メモリ / Redis に保存(sid -> userId)
3. サーバー - [ Set-Cookie: sid=xxx ] -> クライアント(レスポンスヘッダー)
4. クライアント - [ Cookie: sid=xxx ] -> サーバー(2回目以降の全リクエスト)
【超重要】Cookie に付与すべき必須セキュリティ属性
Cookie にセッション ID を乗せてやり取りする際、以下の属性を設定しないと脆弱性(XSS や CSRF など)の温床になります。
- HttpOnly: JavaScript(document.cookie)からのアクセスを禁止し、XSS 攻撃によるセッションハイジャックを防ぐ。
- Secure: HTTPS 通信時のみ Cookie を送信する(ブラウザは localhost に限り HTTP でも許可する)。
- SameSite=Lax(または Strict): 外部サイトからのクロスサイトリクエスト時に Cookie 送信を制限し、CSRF 攻撃を防ぐ。
注意:
express-session の標準のセッション保存先(MemoryStore)はサーバーのメモリ上にあるため、サーバーを再起動するとログイン状態が消え、複数台構成にも対応できません。
今回の動作確認用としては十分ですが、本番では Redis などの外部ストアを指定します。
4. 実践コード:パスワードハッシュ化ユーティリティとユーザー登録/ログイン実装

それでは必要なパッケージをインストールして実装に入りましょう!
今回も第12〜15回と同じ Controller → Service → Repository のレイヤー構成を守るわよ。
Controller から直接 Prisma を呼ぶのはナシ!
1. パッケージのインストール
npm install bcrypt express-session
npm install -D @types/bcrypt @types/express-session
補足:
npm 11.19 以降では、パッケージのインストールスクリプトが初期状態でブロックされ、npm warn install-scripts と表示されます。
bcrypt 6 はビルド済みのバイナリを同梱しているので、スクリプトを実行しなくても動きます(npm install-scripts deny bcrypt で警告を止められます)。
Prisma と tsx(esbuild)の分は、第13回の時点で許可していなければ npm install-scripts approve prisma @prisma/engines esbuild を実行してください。
2. パスワードハッシュ化ユーティリティ(src/utils/password.util.ts)
// src/utils/password.util.ts
import bcrypt from 'bcrypt';
// ストレッチングのコストファクター(標準値: 10〜12)
const SALT_ROUNDS = 10;
/**
* 平文パスワードをソルト付きでハッシュ化する
*/
export const hashPassword = async (password: string): Promise<string> => {
return await bcrypt.hash(password, SALT_ROUNDS);
};
/**
* 平文パスワードとハッシュ値を比較・検証する
*/
export const comparePassword = async (
plainText: string,
hashedText: string
): Promise<boolean> => {
return await bcrypt.compare(plainText, hashedText);
};
3. スキーマ定義への User モデル追加(prisma/schema.prisma)
第13回で作った schema.prisma の末尾に、以下の User モデルを追記します。
// prisma/schema.prisma(末尾に追記)
// ユーザーモデル
model User {
id Int @id @default(autoincrement())
email String @unique @db.VarChar(255)
password String @db.VarChar(255) // ハッシュ化されたパスワードを格納
name String @db.VarChar(100)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
スキーマを追加したら、マイグレーションを実行して Prisma Client を再生成します。
npx prisma migrate dev --name add_user_model
npx prisma generate

あれ、migrate dev のあとに generate も要るんですか?

Prisma 6 までは migrate dev が自動で Client を作り直してくれたけど、Prisma 7 からは migrate dev は DB を更新するだけになったの。
generate を忘れると、生成済みの Client に User がないままだから、prisma.user で型エラーになるわよ。

@unique を付けたので、第15回で話していた P2002 がここで出てくるんですね!

そう。
メールアドレスの重複チェックを『先に検索して、無ければ作成』と書くと、同時に2人が登録した場合にすり抜けてしまうの。
DB の一意制約に任せて、エラー(P2002)を ConflictError に変換するのが確実よ。
第15回の toAppError がそのまま使えるわ。
4. エラークラスの追加と型定義
認証失敗用の UnauthorizedError(401)を app.error.ts に追加します。
// src/errors/app.error.ts
// ベースとなるアプリケーション例外
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
// 不正なリクエスト (400)
export class BadRequestError extends AppError {
constructor(message = 'リクエストの内容が正しくありません') {
super(message, 400, 'BAD_REQUEST');
}
}
// リソース未存在エラー (404)
export class NotFoundError extends AppError {
constructor(message = '指定されたリソースが見つかりません') {
super(message, 404, 'NOT_FOUND');
}
}
// 一意制約・衝突エラー (409)
export class ConflictError extends AppError {
constructor(message = 'リソースが既に存在するか衝突しています') {
super(message, 409, 'CONFLICT');
}
}
// 認証エラー (401)
export class UnauthorizedError extends AppError {
constructor(message = '認証が必要です') {
super(message, 401, 'UNAUTHORIZED');
}
}
パスワードのハッシュ値を API やサービス層に漏らさないよう、ユーザーの型を2つに分けます。
// src/types/user.type.ts
// API やサービス層で扱うユーザー(パスワードを含まない)
export interface User {
id: number;
email: string;
name: string;
}
// ログイン検証でだけ使う、ハッシュ化済みパスワード付きのユーザー
export interface UserCredentials extends User {
passwordHash: string;
}
5. Repository 層(インターフェースと Prisma 実装)
// src/repositories/interfaces/user.repository.interface.ts
import { User, UserCredentials } from '../../types/user.type.js';
export interface IUserRepository {
findById(id: number): Promise<User | undefined>;
findCredentialsByEmail(email: string): Promise<UserCredentials | undefined>;
/** メールアドレスが重複している場合は ConflictError を投げる */
create(data: { email: string; name: string; passwordHash: string }): Promise<User>;
}
// src/repositories/prisma/prisma-user.repository.ts
import { PrismaClient } from '../../generated/prisma/client/client.js';
import { IUserRepository } from '../interfaces/user.repository.interface.js';
import { User, UserCredentials } from '../../types/user.type.js';
import { toAppError } from './prisma-error.util.js';
export class PrismaUserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaClient) {}
private mapToDomain(raw: { id: number; email: string; name: string }): User {
return { id: raw.id, email: raw.email, name: raw.name };
}
async findById(id: number): Promise<User | undefined> {
const user = await this.prisma.user.findUnique({ where: { id } });
return user ? this.mapToDomain(user) : undefined;
}
async findCredentialsByEmail(email: string): Promise<UserCredentials | undefined> {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) return undefined;
return { ...this.mapToDomain(user), passwordHash: user.password };
}
async create(data: { email: string; name: string; passwordHash: string }): Promise<User> {
try {
const created = await this.prisma.user.create({
data: { email: data.email, name: data.name, password: data.passwordHash },
});
return this.mapToDomain(created);
} catch (error) {
// P2002(email の一意制約違反)→ ConflictError(409)
throw toAppError(error, { conflict: 'このメールアドレスは既に登録されています' });
}
}
}
6. 入力バリデーション(src/schemas/auth.schema.ts)
第11回の Zod スキーマと validateRequest をここでも使います。bcrypt の 72 バイト制限もここで弾きます。
// src/schemas/auth.schema.ts
import { z } from 'zod';
const emailSchema = z.email('メールアドレスの形式が正しくありません。').trim().toLowerCase();
export const registerSchema = z.object({
email: emailSchema,
// bcrypt は先頭 72 バイトまでしか使わないため、上限を設ける
password: z
.string()
.min(8, 'パスワードは8文字以上で指定してください。')
.refine((v) => Buffer.byteLength(v, 'utf8') <= 72, 'パスワードは72バイト以内で指定してください。'),
name: z.string().trim().min(1, '名前は必須です。').max(100),
});
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1, 'パスワードは必須です。'),
});
7. Service 層(src/services/auth.service.ts)
ユーザーが存在しない場合とパスワードが違う場合で同じメッセージを返しているのは、攻撃者に「そのメールアドレスは登録されているか」を悟らせないためです。
// src/services/auth.service.ts
import { IUserRepository } from '../repositories/interfaces/user.repository.interface.js';
import { User } from '../types/user.type.js';
import { hashPassword, comparePassword } from '../utils/password.util.js';
import { UnauthorizedError } from '../errors/app.error.js';
export class AuthService {
constructor(private readonly userRepository: IUserRepository) {}
register = async (input: { email: string; password: string; name: string }): Promise<User> => {
const passwordHash = await hashPassword(input.password);
// 重複チェックは DB の一意制約に任せる(Repository が P2002 を ConflictError に変換)
return await this.userRepository.create({
email: input.email,
name: input.name,
passwordHash,
});
};
login = async (email: string, password: string): Promise<User> => {
const credentials = await this.userRepository.findCredentialsByEmail(email);
// ユーザー不在とパスワード不一致を同じメッセージにして、アカウントの有無を悟らせない
const message = 'メールアドレスまたはパスワードが正しくありません';
if (!credentials) {
throw new UnauthorizedError(message);
}
const isValid = await comparePassword(password, credentials.passwordHash);
if (!isValid) {
throw new UnauthorizedError(message);
}
return { id: credentials.id, email: credentials.email, name: credentials.name };
};
getProfile = async (userId: number): Promise<User> => {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new UnauthorizedError('ユーザーが存在しません');
}
return user;
};
}
8. Controller 層(src/controllers/auth.controller.ts)
ログイン成功時に req.session.regenerate() でセッション ID を作り直しています。
ログイン前に攻撃者が用意したセッション ID をそのまま使い続けさせる セッション固定攻撃 への対策です。
req.session.userId を使うために、express-session の型も拡張します(src/types/express-session.d.ts)。
// src/types/express-session.d.ts
import 'express-session';
// express-session の型拡張(セッションデータに userId を持たせる)
declare module 'express-session' {
interface SessionData {
userId: number;
}
}
// src/controllers/auth.controller.ts
import { Request, Response } from 'express';
import { AuthService } from '../services/auth.service.js';
export class AuthController {
constructor(private readonly authService: AuthService) {}
// ユーザー新規登録
register = async (req: Request, res: Response): Promise<void> => {
const { email, password, name } = req.body;
const user = await this.authService.register({ email, password, name });
res.status(201).json(user);
};
// ログイン処理(セッション生成)
login = async (req: Request, res: Response): Promise<void> => {
const { email, password } = req.body;
const user = await this.authService.login(email, password);
// セッション固定攻撃対策:ログイン成功時にセッション ID を必ず作り直す
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => (err ? reject(err) : resolve()));
});
// セッションにユーザー ID を記録(セッション ID が Cookie に設定される)
req.session.userId = user.id;
res.status(200).json({
message: 'ログインに成功しました',
user,
});
};
// ログイン中のユーザー情報(Cookie が送られているかの確認用)
me = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId;
if (userId === undefined) {
res.status(401).json({ error: 'UNAUTHORIZED', message: 'ログインしていません' });
return;
}
const user = await this.authService.getProfile(userId);
res.json(user);
};
// ログアウト(サーバー側のセッションを破棄)
logout = async (req: Request, res: Response): Promise<void> => {
await new Promise<void>((resolve, reject) => {
req.session.destroy((err) => (err ? reject(err) : resolve()));
});
res.clearCookie('connect.sid');
res.status(204).send();
};
}
9. ルーティングと DI(src/routes/auth.route.ts)
第14回と同じく、ルーティング層で prisma → Repository → Service → Controller の順に組み立てます。
// src/routes/auth.route.ts
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { PrismaUserRepository } from '../repositories/prisma/prisma-user.repository.js';
import { AuthService } from '../services/auth.service.js';
import { AuthController } from '../controllers/auth.controller.js';
import { validateRequest } from '../middlewares/validate.middleware.js';
import { registerSchema, loginSchema } from '../schemas/auth.schema.js';
// 依存関係の組み立て(DI)
const userRepository = new PrismaUserRepository(prisma);
const authService = new AuthService(userRepository);
const authController = new AuthController(authService);
const router = Router();
router.post('/register', validateRequest({ body: registerSchema }), authController.register);
router.post('/login', validateRequest({ body: loginSchema }), authController.login);
router.get('/me', authController.me);
router.post('/logout', authController.logout);
export default router;
10. Session ミドルウェアの設定と組み込み
セッションの設定は src/config/session.ts にまとめます。
シークレットが未設定のまま起動しないよう、起動時にチェックします。
// src/config/session.ts
import session from 'express-session';
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error('SESSION_SECRET が .env に設定されていません。');
}
// ※ デフォルトの MemoryStore は開発用。本番では Redis などの外部ストアを指定する
export const sessionMiddleware = session({
secret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // XSS対策 (JSからのCookie読み取り不可)
secure: process.env.NODE_ENV === 'production', // 本番は HTTPS 時のみ送信
sameSite: 'lax', // CSRF対策
maxAge: 1000 * 60 * 60 * 24, // 有効期限: 1日
},
});
.env にもシークレットを追加します(ランダムな長い文字列にしてください)。
# .env(追記)
SESSION_SECRET="change-me-to-a-long-random-string"
src/app.ts に Session ミドルウェアと認証ルートを組み込みます。
session はルーティングより前に登録します。
また、session.ts は import された時点で SESSION_SECRET を読むため、import ‘dotenv/config’; を app.ts の一番最初に書いて、先に .env を読み込ませています。
// src/app.ts
import 'dotenv/config';
import express, { Application, Request, Response } from 'express';
import { sessionMiddleware } from './config/session.js';
import authRouter from './routes/auth.route.js';
import cartRouter from './routes/cart.route.js';
import { errorHandler } from './middlewares/error.middleware.js';
const app: Application = express();
const PORT = 3000;
app.use(express.json());
// セッションミドルウェア(ルーティングより前に登録する)
app.use(sessionMiddleware);
app.use('/api/auth', authRouter);
app.use('/api/cart', cartRouter);
app.get('/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});
app.use((req: Request, res: Response) => {
res.status(404).json({
error: 'NOT_FOUND',
message: '要求されたエンドポイントが存在しません。',
});
});
app.use(errorHandler);
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, () => {
console.log(`[Server] App running on http://localhost:${PORT}`);
});
}
export default app;
5. curl による動作確認:登録・ログイン・Cookie の検証
npm run dev
1. ユーザー登録(POST リクエスト)
curl -i -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "kumaki@example.com", "password": "MySecretPassword123", "name": "熊木"}'
実行結果:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{
"id": 1,
"email": "kumaki@example.com",
"name": "熊木"
}
同じメールアドレスで再登録すると、P2002 が ConflictError に変換されて 409 Conflict が返ります。
HTTP/1.1 409 Conflict
Content-Type: application/json; charset=utf-8
{
"error": "CONFLICT",
"message": "このメールアドレスは既に登録されています"
}
不正な入力は Zod が 400 で弾きます。
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{
"error": "VALIDATION_ERROR",
"message": "リクエストデータに不備があります。",
"details": [
{
"field": "email",
"message": "メールアドレスの形式が正しくありません。",
"rule": "invalid_format"
},
{
"field": "password",
"message": "パスワードは8文字以上で指定してください。",
"rule": "too_small"
},
{
"field": "name",
"message": "名前は必須です。",
"rule": "too_small"
}
]
}
2. ログイン実行とレスポンスヘッダーの Set-Cookie 確認
Cookie をファイルに保存するため、-c オプションを付けます。
curl -i -c cookies.txt -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "kumaki@example.com", "password": "MySecretPassword123"}'
実行結果(レスポンスヘッダーの抜粋):
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Set-Cookie: connect.sid=s%3AqWQQQOmFxC...; Path=/; Expires=Tue, 13 Oct 2026 05:51:43 GMT; HttpOnly; SameSite=Lax
{
"message": "ログインに成功しました",
"user": {
"id": 1,
"email": "kumaki@example.com",
"name": "熊木"
}
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Set-Cookie: connect.sid=s%3AqWQQQOmFxC...; Path=/; Expires=Tue, 13 Oct 2026 05:51:43 GMT; HttpOnly; SameSite=Lax
{
"message": "ログインに成功しました",
"user": {
"id": 1,
"email": "kumaki@example.com",
"name": "熊木"
}
}

レスポンスの Set-Cookie に HttpOnly や SameSite=Lax がしっかり付与されていますね!
3. Cookie を送ってログイン状態を確認する
保存した Cookie を -b オプションで送ると、サーバー側のセッションからユーザーが特定されます。
curl -i -b cookies.txt http://localhost:3000/api/auth/me
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"id": 1,
"email": "kumaki@example.com",
"name": "熊木"
}
Cookie なし(未ログイン)で呼ぶと 401 です。
curl -i http://localhost:3000/api/auth/me
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
{
"error": "UNAUTHORIZED",
"message": "ログインしていません"
}
4. ログアウトとパスワードのハッシュ値の確認
curl -i -b cookies.txt -c cookies.txt -X POST http://localhost:3000/api/auth/logout
HTTP/1.1 204 No Content Set-Cookie: connect.sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
最後に DB の中身を覗いてみます(-x は1件ずつ縦に表示するオプションです)。
docker exec -it dev-postgres psql -U postgres -d my_layered_db -x -c 'SELECT id, email, name, password FROM "User";'
-[ RECORD 1 ]---------------------------------------------------------- id | 1 email | kumaki@example.com name | 熊木 password | $2b$10$RbmimTFlqUPl0V8wzoNua.9UcbsMIurCXB32/hJpcZDAvKMClcjMm

DB 内のパスワードが $2b$10$… という暗号みたいなハッシュ値になっていて、元のパスワードが一切見えなくなっています!

$2b$10$ の 10 が先ほどの SALT_ROUNDS(コスト)よ。
ソルトもこの文字列の中に含まれているから、別の列に保存する必要はないの。
本日のまとめ
- ハッシュ化の絶対ルール:
パスワードは可逆な暗号化ではなく不可逆なハッシュ化(bcrypt / Argon2)で保存する。ソルトとストレッチングにより総当たり・辞書攻撃を防ぐ。
bcrypt は先頭 72 バイトまでしか使われない点に注意する。 - セッション認証とステート保持:
ログイン成功時にサーバー側でセッションを生成し、クライアントにはセッション ID のみを Cookie 経由で受け渡す。
ログイン時に regenerate() でセッション ID を作り直してセッション固定攻撃を防ぐ。 - Cookie のセキュリティ防御線:
HttpOnly(XSS 対策)、Secure(通信盗聴対策)、SameSite(CSRF 対策)を適切に設定する。 - レイヤー構成と P2002:
認証も Controller → Service → Repository で分離し、メールアドレスの重複は DB の一意制約(P2002)を ConflictError に変換して扱う。
次回:第17回「ステートレスな認証の主役!JWT(JSON Web Token)によるセキュアな認可システム」へ続く

