
シリーズ:SvelteKit 認証完全ガイド
01
bcrypt・Prisma・サインアップ
02
セッションDB永続化・有効期限
03
CSRF 対策・Cookie セキュリティ
04
OAuth・GitHub・本番チェックリスト
前シリーズ「 SvelteKit Hooks 完全ガイド 」でhooks.server.ts のhandle ・event.cookies ・Form Actions によるログイン実装まで扱いました。本シリーズはその続きです。

SvelteKit Hooks 完全ガイド
前シリーズで実装したログイン・ログアウトは動きますが、本番には持っていけない部分が3つあります。今回はそれらをすべて解消します。
❌ シリーズBの実装(問題あり)
- パスワードをコードに平文でハードコード
- ユーザー情報が定数
MOCK_USERのみ - セッションがサーバーメモリ(再起動で消える)
- サインアップ(ユーザー登録)なし
✅ このシリーズCの最終形
- bcrypt でパスワードをハッシュ化して保存
- Prisma + SQLite の DB でユーザー管理
- セッションを DB に永続化(第2回)
- サインアップフォームで新規登録可能
💡 前提条件
シリーズBで作成したsvelte-shop/ プロジェクトを使います。src/hooks.server.ts ・src/lib/server/session.ts ・ログイン/ログアウトページが存在する状態で進めます。Node.js 18 以上が必要です(Prisma の要件)。
Prisma + SQLite のセットアップ
💡 Prisma とは
Prisma は TypeScript ファーストの ORM(Object-Relational Mapping)です。スキーマファイル(schema.prisma )にモデルを定義するとマイグレーションと型安全なクライアントが自動生成されます。今回は追加インフラ不要の SQLite を使い、ローカルファイルにデータを保存します。
インストール
# Prisma CLI(開発用)と Prisma Client(実行時)をインストール
$ npm install prisma --save-dev
$ npm install @prisma/client
# Prisma を SQLite で初期化(schema.prisma と .env が生成される)
$ npx prisma init --datasource-provider sqlite
✔ Your Prisma schema was created at prisma/schema.prisma
You can now open it in your favorite editor.
Next steps:
1. Set the DATABASE_URL in the .env file
2. Set the provider of the datasource block to "sqlite"
3. Run npx prisma db push to create the database 生成された.env を確認します。SQLite はファイルパスを指定するだけで動きます。
# SQLite のDBファイルのパス(prisma/ フォルダ内に作られる)
DATABASE_URL="file:./dev.db"✨ .env を .gitignore に追加する
.env には将来的に秘密鍵を書く可能性があります。npx prisma init が自動で.gitignore に追加しますが、念のため確認してください。またprisma/dev.db (SQLite ファイル)も.gitignore に追加することを推奨します。
スキーマの定義とマイグレーション
今回はUser モデルを定義します。セッションの永続化は第2回でSession モデルを追加します。
Prisma Schemaprisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique // メールアドレスは一意
name String
hashedPassword String // 平文パスワードは保存しない
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}スキーマを定義したらマイグレーションを実行してDBを作成します。
# マイグレーションファイルを生成してDBに適用
$ npx prisma migrate dev --name init-user
✔ Generated Prisma Client (v6.x.x)
The following migration(s) have been created and applied:
migrations/
└─ 20240101000000_init_user/
└─ migration.sql
✔ Generated Prisma Client
# Prisma Studio でDBの中身をブラウザから確認できる(任意)
$ npx prisma studio💡 migrate dev と db push の違い
migrate dev はマイグレーションファイルを生成・記録してからDBに適用します。変更履歴が残るため本番環境での運用に向いています。db push はファイルを生成せず直接DBに反映するため、プロトタイプ段階での素早い試行に向いています。このシリーズではmigrate dev を使います。
Prisma Client のシングルトンを作る
Prisma Client は1つのインスタンスを使い回す(シングルトン)のが推奨パターンです。開発中はホットリロードのたびに新しいインスタンスが作られてコネクションが枯渇するのを防ぎます。
import { PrismaClient } from '@prisma/client';
// グローバル変数として保持してホットリロード時の多重生成を防ぐ
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
} bcrypt によるパスワードハッシュ化
なぜハッシュ化が必要か
パスワードを平文で保存すると、DBが漏洩した瞬間にすべてのユーザーのパスワードが露出します。ハッシュ化すると元のパスワードに戻すことができないため、漏洩してもパスワードそのものは守られます。
登録時 :
password123
→
bcrypt.hash()
→
$2b$10$Kd9l5XmVQ2...(DBに保存)
ログイン時:
password123
+
$2b$10$Kd9l5XmVQ2...(DBに保存)
→
bcrypt.compare()
→
true ✓
攻撃者 :
wrongpass
+
$2b$10$Kd9l5XmVQ2...(DBに保存)
→
bcrypt.compare()
→
false ✗
bcryptjs のインストール
Node.js ネイティブのbcrypt はコンパイルが必要なため、純粋な JavaScript 実装のbcryptjs を使います。SvelteKit との相性も良好です。
$ npm install bcryptjs
$ npm install --save-dev @types/bcryptjsハッシュ化と検証のユーティリティ関数を作ります。
import bcrypt from 'bcryptjs';
// saltRounds: 数値が大きいほど安全だが処理が遅くなる。10〜12が推奨
const SALT_ROUNDS = 12;
// パスワードをハッシュ化する(登録・パスワード変更時に使う)
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
// パスワードとハッシュを比較する(ログイン時に使う)
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}✨ saltRounds について
bcrypt はハッシュ化の際にランダムな「salt」を自動で付加します。同じパスワードでもハッシュ結果が毎回異なるため、レインボーテーブル攻撃を無効化できます。SALT_ROUNDS = 12 は現在のCPU性能で1回のハッシュ化に約250ms かかる程度で、ログイン体験に影響を与えない範囲で十分な安全性を確保できます。
サインアップの実装
新規ユーザー登録フローを実装します。メールアドレスの重複チェック・パスワードのバリデーション・bcrypt によるハッシュ化・DBへの保存・登録後の自動ログインまでを一気に実装します。
1
フォームからメール・名前・パスワードを受け取る
Form Actions のrequest.formData() で取得する。
2
メールアドレスの重複チェック
メールアドレスの重複チェック Prisma で同じメールのユーザーを検索。存在すればエラーを返す。
3
パスワードをハッシュ化してDBに保存
hashPassword() を呼び、ハッシュをhashedPassword に保存する。平文は絶対に保存しない。
4
セッションを作成して自動ログイン
セッションを作成して自動ログイン 登録完了後すぐにログイン状態にすることで UX を向上させる。
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { hashPassword } from '$lib/server/password';
import { createSession } from '$lib/server/session';
// すでにログイン済みならリダイレクト
export const load: PageServerLoad = async (event) => {
if (event.locals.user) redirect(303, '/mypage');
return {};
};
export const actions: Actions = {
signup: async (event) => {
const data = await event.request.formData();
const email = String(data.get('email')).trim().toLowerCase();
const name = String(data.get('name')).trim();
const password = String(data.get('password'));
// ── バリデーション ──
if (!email || !name || !password) {
return fail(400, { message: 'すべての項目を入力してください' });
}
if (password.length < 8) {
return fail(400, { message: 'パスワードは8文字以上で入力してください' });
}
// ── メールアドレスの重複チェック ──
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return fail(409, { message: 'このメールアドレスはすでに登録されています' });
}
// ── パスワードをハッシュ化してDBに保存 ──
const hashedPassword = await hashPassword(password);
const user = await db.user.create({
data: { email, name, hashedPassword },
});
// ── 登録完了後に自動ログイン ──
const sessionId = createSession({
id: user.id, name: user.name, email: user.email
});
event.cookies.set('session_id', sessionId, {
path: '/', httpOnly: true, sameSite: 'lax', secure: false, maxAge: 60 * 60 * 24
});
redirect(303, '/mypage');
},
};<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
export let form: ActionData;
</script>
<h1>新規登録</h1>
<form method="POST" action="?/signup" use:enhance>
{#if form?.message}
<p class="error">{form.message}</p>
{/if}
<label>
お名前
<input type="text" name="name" required />
</label>
<label>
メールアドレス
<input type="email" name="email" required />
</label>
<label>
パスワード(8文字以上)
<input type="password" name="password" minlength="8" required />
</label>
<button type="submit">登録する</button>
</form>
<p>すでにアカウントをお持ちの方は <a href="/login">ログイン</a></p> ログインをDB照合に差し替える
シリーズBのMOCK_USER との固定値比較を、Prisma クエリ + bcrypt 検証に差し替えます。変更箇所はsrc/routes/login/+page.server.ts のactions.login のみです。
import { redirect, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { verifyPassword } from '$lib/server/password';
import { createSession } from '$lib/server/session';
export const load: PageServerLoad = async (event) => {
if (event.locals.user) redirect(303, '/mypage');
return {};
};
export const actions: Actions = {
login: async (event) => {
const data = await event.request.formData();
const email = String(data.get('email')).trim().toLowerCase();
const password = String(data.get('password'));
// ── DBからユーザーを検索 ──
const user = await db.user.findUnique({ where: { email } });
// ── bcrypt でパスワード検証 ──
// ユーザーが存在しない場合もダミーのハッシュと比較してタイミング攻撃を防ぐ
const dummyHash = '$2b$12$dummy.hash.to.prevent.timing.attack.aaaa';
const isValid = await verifyPassword(
password,
user?.hashedPassword ?? dummyHash
);
if (!user || !isValid) {
// ユーザーが存在しない場合と、パスワードが違う場合で
// 同じメッセージを返すことで、メールアドレスの存在有無を隠す
return fail(401, { message: 'メールアドレスまたはパスワードが違います' });
}
// ── セッション作成・Cookie 発行 ──
const sessionId = createSession({
id: user.id, name: user.name, email: user.email
});
event.cookies.set('session_id', sessionId, {
path: '/', httpOnly: true, sameSite: 'lax', secure: false, maxAge: 60 * 60 * 24
});
const redirectTo = event.url.searchParams.get('redirectTo') ?? '/mypage';
redirect(303, redirectTo);
},
};⚠️ ️ タイミング攻撃への対策
「メールアドレスが存在しない場合は即座にエラーを返す」実装にすると、攻撃者はレスポンスの速さでメールアドレスの存在を判別できます(タイミング攻撃)。ユーザーが存在しない場合もダミーのハッシュとverifyPassword を実行することで、処理時間を均一化しています。
トラブルシュート
❓ npx prisma migrate dev でエラーになる
.env のDATABASE_URL が正しく設定されているか確認してください。SQLite の場合file:./dev.db のようにfile: プレフィックスが必要です。またprisma/ フォルダが存在しない場合は先にnpx prisma init を実行してください。
❓ PrismaClient をインポートするとサーバーでしか動かないエラーが出る
src/lib/server/db.ts に置いているはずの Prisma Client を、.server なしのファイル(+page.ts や+layout.ts )からインポートしていないか確認してください。Prisma Client はサーバー専用です。必ず+page.server.ts や+layout.server.ts 、またはsrc/lib/server/ 配下のファイルからのみインポートしてください。
❓ サインアップ後にログインしようとするとパスワードが違うと言われる
登録時にhashedPassword ではなく平文のpassword を DB に保存していないか確認してください。db.user.create のdata に渡しているのがhashPassword() の戻り値であることを確認します。
第1回の完成コード一覧
prisma/
└─ schema.prisma NEW ← User モデル
src/
├ lib/server/
│ ├─ db.ts UPDATE ← Prisma Client シングルトン
│ └─ password.ts NEW ← hashPassword / verifyPassword
│
└ routes/
├─ signup/
│ ├─ +page.server.ts NEW ← actions.signup
│ └─ +page.svelte NEW ← サインアップフォーム
└─ login/
└─ +page.server.ts UPDATE ← DB照合・bcrypt検証に差し替え📄
prisma/schema.prisma
User モデル定義
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
hashedPassword String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}📄
src/lib/server/db.ts
Prisma Client シングルトン
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;📄
src/lib/server/password.ts
bcrypt ハッシュ化・検証ユーティリティ
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
} 第1回のまとめ
今回学んだこと
- Prisma + SQLite のセットアップ ―
schema.prismaでモデルを定義しmigrate devでDBを作成する流れを実践した - Prisma Client はシングルトンパターンで管理することで開発中のホットリロード時のコネクション枯渇を防げる
- bcrypt はパスワードをハッシュ化して保存し、
compare()で元のパスワードと比較できる。平文パスワードは絶対に保存しない - サインアップでは「メール重複チェック → ハッシュ化 → DB保存 → 自動ログイン」の4ステップが基本フロー
- ログイン時はユーザーが存在しない場合もダミーのハッシュと比較してタイミング攻撃を防ぐ
- エラーメッセージは「メールアドレスまたはパスワードが違います」とまとめることでメールアドレスの存在有無を隠す
🎯 第1回のまとめ
シリーズBの「ハードコードユーザー + 平文パスワード」から「DB管理 + bcrypt ハッシュ化」への移行が完了しました。次回はセッション管理をMap (メモリ)から Prisma の Session テーブルへ移行し、有効期限と自動更新を実装します。
[作成中] 次回(第2回)
次の記事「 」でDB永続化の説明を行います。