
SvelteKitのhandleで認証を一元化する:Cookie・セッション・Form Actions実装
SvelteKitのhandleを使って認証処理を一元化する方法を解説。event.cookiesによるCookie操作、セッション管理、Form Actionsを使ったログイン実装、保護ルートの制御まで学べます。
シリーズ:SvelteKit Hooks 完全ガイド
01
Hooks の基本と3ファイルの違い
02
handle で認証を一元化する
03
handleError・handleFetch・クライアント
第1回ではhandle 関数の基本とlocals の仕組みを学びました。最後にlocals.user をダミーの固定値でセットして終わっていましたが、今回はここを本物の認証に差し替えます。
具体的には ①event.cookies を使ったセッション管理 、 ② Form Actions によるログインフォームの実装 、 ③handle で保護ルートを一元管理 、この3つを順番に実装します。シリーズAのマイページで書いていた個別の認証チェックが、最終的にhooks.server.ts の1か所に集約されます。
🚀 前提条件
第1回で作成したsrc/hooks.server.ts とsrc/app.d.ts が存在する状態で進めます。また今回の実装ではセッションをサーバーメモリ上のMap で管理します(開発・学習用途)。本番向けのDB・Redis を使ったセッション管理はシリーズCで扱います。
💡 認証の詳細は次のシリーズで説明
今回実装する認証は「仕組みを理解するための最小実装」です。パスワードのハッシュ化・CSRF対策・セッションの有効期限管理・DBへの永続化といった本番運用に必要なトピックは、次のシリーズ「 SvelteKit 認証完全ガイド 」で詳しく扱います。
各ページに書く認証チェックの何が問題か
前シリーズ(SvelteKit サーバー・クライアント動作入門) の第3回でマイページに以下のコードを書きました。
// src/routes/(shop)/mypage/+page.server.ts
export const load: PageServerLoad = async (event) => {
// ページごとに同じ認証チェックを書く必要がある ← 問題
const loggedIn = event.url.searchParams.get('loggedIn') === 'true';
if (!loggedIn) redirect(303, '/login');
return { user: { name: '山田 太郎', email: 'taro@example.com', memberSince: '2024-01' } };
}; 将来マイページの他に「注文履歴」「お気に入り」など認証が必要なページが増えるたびに、同じ認証チェックを各+page.server.ts にコピーする必要があります。チェックロジックを変更するたびにすべてのファイルを修正しなければならず、 漏れが即セキュリティホールになります。
今回実装する最終形 では、認証チェックはhooks.server.ts のhandle に1か所だけ書き、各ページのload はlocals.user を参照するだけになります。
今回実装する認証フローの全体像
① ログイン(Form Actions)
/login
フォーム送信
→
+page.server.ts
actions.login()
→
sessionStore
セッションID生成・保存
→
Cookie
session_id 発行
② 以降のリクエスト(handle で検証)
ページリクエスト
→
hooks.server.ts handle()
Cookie → sessionStore 検証
→
✓ 検証OK
locals.user にセット
→
✗ 未認証
/login にリダイレクト
③ ページで locals を参照
+page.server.ts load()
event.locals.user を参照
→
+page.server.ts load()
event.locals.user を参照
セッションストアの準備
🖥 サーバーのみで実行
ログイン時に生成したセッションIDと、それに紐づくユーザー情報をサーバー側で管理する仕組みが必要です。今回はシンプルに サーバーメモリ上のMap を使います。
⚠️ ️ Map によるセッション管理の制限
サーバーを再起動するとセッションが消えます。また複数サーバーで負荷分散する構成では共有できません。本番環境では Redis や DB を使った永続セッションが必要です。これらの実装は 次のシリーズ で扱います。
import { randomUUID } from 'node:crypto';
export type SessionUser = {
id: string;
name: string;
email: string;
};
// セッションIDとユーザー情報を紐づけるMap(サーバーメモリ上)
const sessionStore = new Map<string, SessionUser>();
// セッションを作成してIDを返す
export function createSession(user: SessionUser): string {
const sessionId = randomUUID();
sessionStore.set(sessionId, user);
return sessionId;
}
// セッションIDからユーザー情報を取得。存在しなければ null
export function getSession(sessionId: string): SessionUser | null {
return sessionStore.get(sessionId) ?? null;
}
// ログアウト時にセッションを削除
export function deleteSession(sessionId: string): void {
sessionStore.delete(sessionId);
} 次にapp.d.ts のLocals 型をSessionUser を使うように更新します。
import type { SessionUser } from '$lib/server/session';
declare global {
namespace App {
interface Locals {
user: SessionUser | null;
}
}
}
export {}; event.cookies ― Cookie の読み書き
🖥 サーバーのみで実行
event.cookies は Cookie を読み書きするためのAPIです。get で読み取り、set で書き込み、delete で削除します。
// Cookie を読む
const sessionId = event.cookies.get('session_id');
// Cookie を書く
event.cookies.set('session_id', sessionId, {
path: '/', // Cookie が有効なパス
httpOnly: true, // JavaScript からアクセス不可(XSS対策)
sameSite: 'lax', // CSRF対策('strict' / 'lax' / 'none')
secure: true, // HTTPS 環境のみ送信(本番では必ず true)
maxAge: 60 * 60 * 24, // 有効期限(秒)。ここでは24時間
});
// Cookie を削除
event.cookies.delete('session_id', { path: '/' }); 主要オプションの詳細
httpOnly
true(推奨)
JavaScript のdocument.cookie からアクセスできなくなる。XSS攻撃でCookieが盗まれるリスクを大幅に下げる。セッションCookieには必ずtrue を指定する。
sameSite
'lax'(標準的な選択)
外部サイトからのリクエストにCookieを付与するかを制御する。'lax' は通常のナビゲーションには付与、フォームのPOSTクロスサイトには付与しない。CSRF対策の基本。
secure
true(推奨)
HTTPS 接続のみCookieを送信する。開発環境(localhost)ではfalse でないと動かないため、環境変数で切り替えるのが定番。
maxAge
秒数(例:60 * 60 * 24 = 1日)
Cookieの有効期限を秒単位で指定。指定しない場合はブラウザを閉じると消えるセッションCookieになる。ログイン維持機能には適切な有効期限が必要。
handle で認証チェックを一元化する
🖥 hooks.server.ts
第1回のダミー実装を、event.cookies とsessionStore を使った本物の認証チェックに差し替えます。
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { getSession } from '$lib/server/session';
// 認証が必要なルートのプレフィックス
const PROTECTED_ROUTES = ['/mypage'];
export const handle: Handle = async ({ event, resolve }) => {
// ① Cookie からセッションIDを取得
const sessionId = event.cookies.get('session_id');
// ② セッションIDがあればユーザー情報を取得して locals にセット
event.locals.user = sessionId ? getSession(sessionId) : null;
// ③ 保護ルートへの未認証アクセスをここで一括ガード
const isProtected = PROTECTED_ROUTES.some(
(route) => event.url.pathname.startsWith(route)
);
if (isProtected && !event.locals.user) {
redirect(303, `/login?redirectTo=${event.url.pathname}`);
}
return resolve(event);
};✨ redirectTo パラメータについて
未認証でリダイレクトする際、?redirectTo=/mypage のようにアクセスしようとしていたURLを渡しています。ログイン成功後にそのURLへ戻すことで UX が向上します。ログインページのactions でこの値を受け取って使います。
これで各ページの認証チェックが不要になります。マイページの+page.server.ts はシンプルになります。
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
// handle で認証済みが保証されているため、ここで redirect は不要
// locals.user は必ず存在する(null チェックは型的な保険)
const user = event.locals.user!;
return { user };
}; Form Actions ミニ解説
📝 📖 Form Actions とは
Form Actions は SvelteKit がフォーム送信を処理する仕組みです。+page.server.ts にexport const actions をエクスポートすると、そのページへのPOST リクエストを受け取れます。
通常の HTML フォームがそのまま動くため、JavaScript が無効な環境でも機能するという特徴があります(Progressive Enhancement)。
import type { Actions } from './$types';
export const actions: Actions = {
// <form method="POST" action="?/login"> に対応するハンドラ
login: async (event) => {
// フォームデータを取得
const data = await event.request.formData();
const email = String(data.get('email'));
// 処理結果をページに返す(エラー表示などに使う)
return { success: true };
}
};<!-- action="?/login" でこのページの login アクションを呼び出す -->
<form method="POST" action="?/login">
<input type="email" name="email" required />
<button type="submit">ログイン</button>
</form> ログインページの実装
Form Actions の知識を使ってログインページを実装します。フォームからメールアドレスとパスワードを受け取り、検証後にセッションを作成してCookieを発行します。
⚠️ ️ パスワード検証について
今回はサンプルのため固定値(password123 )と比較していますが、実際のアプリでは必ずハッシュ化されたパスワードを比較します。bcrypt などを使ったハッシュ化は 次のシリーズ で実装します。
import { redirect, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { createSession } from '$lib/server/session';
// 仮のユーザーデータ(シリーズCでDBに移行)
const MOCK_USER = {
id: 'user-1',
name: '山田 太郎',
email: 'taro@example.com',
password: 'password123', // 本番では絶対にこの形で保存しない
};
// すでにログイン済みなら /mypage へリダイレクト
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'));
const password = String(data.get('password'));
// メール・パスワードを検証(本番ではDBとbcryptで照合)
if (email !== MOCK_USER.email || password !== MOCK_USER.password) {
// fail() でエラーコードとメッセージをページに返す
return fail(401, { message: 'メールアドレスまたはパスワードが違います' });
}
// 認証成功 → セッション作成
const sessionId = createSession({
id: MOCK_USER.id,
name: MOCK_USER.name,
email: MOCK_USER.email,
});
// Cookie にセッションIDを発行
event.cookies.set('session_id', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: false, // 開発環境。本番では true
maxAge: 60 * 60 * 24,
});
// ログイン前にアクセスしようとしていたURLがあれば戻る
const redirectTo = event.url.searchParams.get('redirectTo') ?? '/mypage';
redirect(303, redirectTo);
},
};<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
// actions の戻り値(fail() の内容)が form に入る
export let form: ActionData;
</script>
<h1>ログイン</h1>
<!-- use:enhance でページ全体リロードなしにフォームを送信 -->
<form method="POST" action="?/login" use:enhance>
<!-- fail() で返ったエラーメッセージを表示 -->
{#if form?.message}
<p class="error">{form.message}</p>
{/if}
<label>
メールアドレス
<input type="email" name="email" required />
</label>
<label>
パスワード
<input type="password" name="password" required />
</label>
<button type="submit">ログイン</button>
</form>✨ use:enhance について
use:enhance は SvelteKit が提供する Svelte アクションです。フォームに付けるとページ全体の再読み込みなしに送信・レスポンス処理を行います。付けなくても動作しますが(Progressive Enhancement の恩恵)、付けるとより SPA らしいスムーズな動作になります。
ログアウトの実装
ログアウトは「セッションの削除」と「Cookieの削除」の2つをセットで行います。どちらかを忘れると不完全なログアウトになります。
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { deleteSession } from '$lib/server/session';
export const actions: Actions = {
logout: async (event) => {
const sessionId = event.cookies.get('session_id');
if (sessionId) {
// サーバー側のセッションを削除
deleteSession(sessionId);
// Cookie を削除(path は set 時と同じにする)
event.cookies.delete('session_id', { path: '/' });
}
redirect(303, '/login');
},
};マイページにログアウトボタンを追加します。
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<h1>マイページ</h1>
<dl>
<dt>お名前</dt> <dd>{data.user.name}</dd>
<dt>メールアドレス</dt> <dd>{data.user.email}</dd>
</dl>
<!-- ログアウトは POST で行う(GET でやると CSRF リスク) -->
<form method="POST" action="/logout?/logout">
<button type="submit">ログアウト</button>
</form>🎯 動作確認の手順
①http://localhost:5173/mypage →/login?redirectTo=/mypage にリダイレクトされる。
② メールtaro@example.com ・パスワードpassword123 でログイン → マイページへリダイレクト。
③ ログアウトボタンで/login に戻る。
④ ブラウザの開発者ツール → Application → Cookies でsession_id の発行・削除を確認する。
トラブルシュート
❓ ログインしても Cookie が発行されず、マイページに入れない
cookies.set() のpath オプションを忘れていないか確認してください。path が未指定だと現在のパスにのみCookieが有効になります。またsecure: true を HTTP 環境で使うとCookieが送られません。開発環境ではsecure: false にしてください。
❓ ログアウト後も /mypage にアクセスできてしまう
deleteSession() は呼んでいるがcookies.delete() を忘れているケースが多いです。またはcookies.delete() のpath がset 時と異なっている場合もあります。set とdelete のpath は必ず同じ値にしてください。
❓ handle で redirect しているのに無限リダイレクトが起きる
PROTECTED_ROUTES に'/login' 自体が含まれていないか確認してください。ログインページも保護ルートになっていると、未認証でログインページにアクセス → リダイレクト → ログインページ → リダイレクト… の無限ループになります。
第2回の完成コード一覧
src/
├─ app.d.ts UPDATE
├─ hooks.server.ts UPDATE ← Cookie検証・保護ルートガード追加
├─ lib/server/
└─ session.ts NEW ← セッションストア
└─ routes/
├─ login/
| ├─ +page.server.ts NEW ← actions.login
| └─ +page.svelte NEW ← ログインフォーム
├─ logout/
| └─ +page.server.ts NEW ← actions.logout
└─ (shop)/mypage/
├─ +page.server.ts UPDATE ← 認証チェック削除・locals参照のみに
└─ +page.svelte UPDATE ← ログアウトボタン追加📄
src/lib/server/session.ts
セッションストア(Map によるメモリ管理)
import { randomUUID } from 'node:crypto';
export type SessionUser = { id: string; name: string; email: string };
const sessionStore = new Map<string, SessionUser>();
export function createSession(user: SessionUser): string {
const sessionId = randomUUID();
sessionStore.set(sessionId, user);
return sessionId;
}
export function getSession(sessionId: string): SessionUser | null {
return sessionStore.get(sessionId) ?? null;
}
export function deleteSession(sessionId: string): void {
sessionStore.delete(sessionId);
}📄
src/hooks.server.ts
Cookie 検証・保護ルートガード・セキュリティヘッダー
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { getSession } from '$lib/server/session';
const PROTECTED_ROUTES = ['/mypage'];
export const handle: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get('session_id');
event.locals.user = sessionId ? getSession(sessionId) : null;
const isProtected = PROTECTED_ROUTES.some(
(r) => event.url.pathname.startsWith(r)
);
if (isProtected && !event.locals.user) {
redirect(303, `/login?redirectTo=${event.url.pathname}`);
}
const response = await resolve(event);
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
return response;
};📄
src/routes/login/+page.server.ts
ログイン処理(Form Actions)
import { redirect, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { createSession } from '$lib/server/session';
const MOCK_USER = { id: 'user-1', name: '山田 太郎', email: 'taro@example.com', password: 'password123' };
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'));
const password = String(data.get('password'));
if (email !== MOCK_USER.email || password !== MOCK_USER.password) {
return fail(401, { message: 'メールアドレスまたはパスワードが違います' });
}
const sessionId = createSession({ id: MOCK_USER.id, name: MOCK_USER.name, email: MOCK_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);
},
};📄
src/routes/logout/+page.server.ts
ログアウト処理
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { deleteSession } from '$lib/server/session';
export const actions: Actions = {
logout: async (event) => {
const sessionId = event.cookies.get('session_id');
if (sessionId) {
deleteSession(sessionId);
event.cookies.delete('session_id', { path: '/' });
}
redirect(303, '/login');
},
}; 第2回のまとめ
今回学んだこと
- ✔️ 認証チェックを各ページに書くと変更漏れがセキュリティホールになる。
handleで一元化することで PROTECTED_ROUTES の1行追加だけで保護ページを増やせるようになった - ✔️
event.cookies.set()のhttpOnly・sameSite・secureはセキュリティの基本オプション。意味を理解して使い分ける - ✔️ セッションIDの生成(
randomUUID)→ Mapへの保存 → Cookieへの発行 → 次回リクエストで検証 →localsにセット、という認証フロー全体を実装した - ✔️ Form Actions は
export const actionsでフォーム送信を受け取る仕組み。fail()でエラーをページに返し、use:enhanceでページ全体リロードなしに動作させる - ✔️ ログアウトはサーバー側のセッション削除と Cookie の削除を必ずセットで行う
- ✔️ 今回の実装はパスワードハッシュ化・CSRF対策・セッション永続化を省略した学習用最小実装。本番向けの詳細は 次のシリーズ で扱う
❌ 第2回のまとめ
認証の一元管理が完成し、商品カタログアプリに本物のログイン・ログアウト機能が加わりました。次回はシリーズBの最終回として、 サーバー・クライアント両面のエラー処理 (handleError )と fetch のインターセプト (handleFetch )を実装し、シリーズ全体のコードを完成形に仕上げます。
[作成中] 次回(第3回)
次の記事「 」でエラー処理とfetch制御を学びます。