
SvelteKitのCSRF対策とCookieセキュリティ:本番向け認証を強化する
SvelteKitのCSRF保護の仕組みを理解し、sameSiteやsecureなどのCookie設定、セキュリティヘッダーを本番向けに強化します。
シリーズ:SvelteKit 認証完全ガイド
01
bcrypt・Prisma・サインアップ
02
セッションDB永続化・有効期限
03
CSRF 対策・Cookie セキュリティ
04
OAuth・GitHub・本番チェックリスト
第2回でセッション管理を DB に移行し、有効期限と自動延長まで実装しました。ここまでで認証の基本フローは完成しています。しかし本番運用するには、 攻撃者がユーザーの意図しないリクエストを送り込む「CSRF」 への対策と、 Cookie の各種セキュリティオプション の見直しが必要です。
今回は CSRF とは何か・SvelteKit がどう守っているか・さらに強化するための実装を順番に理解します。最後にhooks.server.ts でセキュリティヘッダーを一括設定して完成させます。
CSRF とは何か ― 攻撃の仕組みを理解する
CSRF(Cross-Site Request Forgery:クロスサイトリクエストフォージェリ)は、攻撃者が ログイン済みユーザーに意図しないリクエストを送らせる攻撃 です。ユーザーが気づかないうちに、攻撃者が用意した罠サイトから本物のサイトへリクエストが送られます。
1
ユーザー
svelte-shop.com にログイン済み。ブラウザには session_id Cookie が保存されている。
2
攻撃者
罠サイト(evil.com)に「無料プレゼント!」などのリンクを用意。ページには隠しフォームが仕込まれている。
3
ユーザー
evil.com を開く。ページが自動的に svelte-shop.com/account/delete へ POST を送信する。
4
ブラウザ
リクエストに svelte-shop.com の Cookie(session_id)を自動付与して送る。
5
サーバー
Cookie が正しいため認証済みと判断し、アカウントを削除してしまう。ユーザーは何もしていないのに被害を受ける。
✨ なぜ Cookie が自動で送られるのか
ブラウザは同じドメインの Cookie をリクエストに自動で付与する仕様です。この仕様を悪用するのが CSRF です。sameSite 属性はこの自動付与を制限します(後述)。
SvelteKit の組み込み CSRF 保護
SvelteKit は Form Actions に対して デフォルトで CSRF 保護が有効 です。どのように保護しているかを理解しましょう。
Origin チェック
SvelteKit は POST / PUT / PATCH / DELETE リクエストを受け取ると、リクエストヘッダーのOrigin またはReferer がサイト自身のドメインと一致するかを自動でチェックします。外部サイト(evil.com)から送られたリクエストは 403 Forbidden で拒否されます。
自サイトのフォーム
Origin: svelte-shop.com
→
SvelteKit Origin チェック
自サイトのドメインと一致?
→
✓ 一致 → 処理を続行
Form Action が実行される
外部サイトの罠フォーム
Origin: evil.com
→
SvelteKit Origin チェック
自サイトのドメインと一致?
→
✗ 不一致 → 403 Forbidden
自動で拒否される
🎯 Form Actions はデフォルトで保護済み
前シリーズ で実装したログイン・サインアップ・ログアウトの Form Actions は、この Origin チェックによって すでに CSRF 対策が適用されています 。特別な実装を追加しなくても外部サイトからのリクエストは自動で拒否されます。
CSRF 保護が効かないケース
Origin チェックは Form Actions には効きますが、以下のケースでは追加対策が必要です。
| ケース | 保護状況 | 対応 |
|---|---|---|
| Form Actions(POST) | ✓ 自動保護 | 追加対策不要 |
+server.ts の GET エンドポイント | 状態を変更しなければ問題なし | GET は副作用を持たせない |
+server.ts への fetch(非フォーム) | Origin チェックは効く | カスタムヘッダーを付与する(後述) |
| CORS を有効にしている API | ✗ 追加対策が必要 | 許可オリジンを厳密に設定する |
カスタムヘッダーによる追加 CSRF 対策
JavaScript のfetch() からリクエストを送る場合(Form Actions 以外のケース)、カスタムヘッダーを付与することで CSRF 対策を強化できます。外部サイトからの単純リクエストにはカスタムヘッダーを付与できないため、サーバー側でチェックすることで弾けます。
クライアント側:fetch に X-Requested-With を付ける
// +page.svelte や hooks.client.ts などブラウザで動くファイルで使う
const response = await fetch('/api/products', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// このヘッダーを付与することで「意図的な XHR/fetch リクエスト」と識別できる
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ name: '新商品' }),
}); サーバー側:handle でカスタムヘッダーをチェック
export const handle: Handle = async ({ event, resolve }) => {
// ... セッション検証・保護ルートチェック(第2回と同じ) ...
// /api/* への POST/PUT/DELETE を追加チェック
const { pathname } = event.url;
const method = event.request.method;
const isMutatingApi = pathname.startsWith('/api/')
&& ['POST', 'PUT', 'DELETE'].includes(method);
if (isMutatingApi) {
const requested = event.request.headers.get('x-requested-with');
if (requested !== 'XMLHttpRequest') {
return new Response('Forbidden', { status: 403 });
}
}
return resolve(event);
}; Cookie セキュリティの強化
前シリーズ でsession_id Cookie を発行しましたが、本番環境に向けた設定の見直しが必要です。各オプションの意味と推奨値を整理します。
sameSite オプションの詳細
CSRF 対策で最も重要な設定がsameSite です。3つの値を比較します。
sameSite: 'strict'
- 外部サイトから一切 Cookie を送らない
- CSRF への最強の防御
- 外部リンクからのアクセスでもログアウト状態になる
- UX が犠牲になりやすい
sameSite: 'lax'
- GET ナビゲーションでは Cookie を送る
- 外部リンクからでもログイン状態を維持
- クロスサイトの POST には送らない
- CSRF への実用的な防御(推奨)
sameSite: 'none'
- すべてのリクエストで Cookie を送る
- CSRF の防御なし
- secure: true が必須
埋め込みウィジェットなど特殊なケース向け
✨ 商品カタログアプリでの推奨設定
外部サイトのリンクからアクセスしてもログイン状態を保ちたい(UX を損ないたくない)場合はsameSite: 'lax' が適切です。SvelteKit のデフォルトも'lax' です。銀行など高セキュリティが求められる場合は'strict' を検討してください。
本番向け Cookie 設定の完成形
ログイン・サインアップで発行する Cookie を環境に応じて切り替えます。
httpOnly: true
必須・変更不要
JavaScript からdocument.cookie でアクセスできなくする。XSS 攻撃でトークンが盗まれるリスクを大幅に軽減。セッション Cookie には必ずtrue 。
sameSite: 'lax'
推奨・現状維持
外部サイトからの POST リクエストには Cookie を送らない。外部リンクからの GET(ページ遷移)には送る。UX と安全性のバランスが良い。
secure: true
本番: true / 開発: false
HTTPS 環境のみ Cookie を送信する。ローカルの HTTP では動かなくなるため、環境変数で切り替える必要がある。
maxAge
60 * 60 * 24(24時間)
Cookie の有効期限。DB のexpiresAt と合わせておく。長すぎると期限切れセッションのゾンビ Cookie が残るリスクがある。
path: '/'
必須・変更不要
サイト全体で Cookie を有効にする。/ を指定しないと特定のパスでのみ有効になり、ログイン状態が途切れる。
環境変数で secure を切り替える
import type { CookieSerializeOptions } from 'cookie';
// Cookie オプションを一か所で管理することで設定の一貫性を保つ
export const SESSION_COOKIE_OPTIONS: CookieSerializeOptions = {
path: '/',
httpOnly: true,
sameSite: 'lax',
// NODE_ENV が production のときのみ secure を true にする
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24,
}; ログイン・サインアップ・ログアウトの各+page.server.ts でこのオプションを使うように統一します。
import { redirect, fail } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { verifyPassword } from '$lib/server/password';
import { createSession } from '$lib/server/session';
import { SESSION_COOKIE_OPTIONS } from '$lib/server/cookie';
// ...(load 関数は省略)...
export const actions = {
login: async (event) => {
// ...(バリデーション・DB照合・セッション作成は省略)...
const sessionId = await createSession({ id: user.id, name: user.name, email: user.email });
// 個別にオプションを書く代わりに一元管理のオブジェクトを使う
event.cookies.set('session_id', sessionId, SESSION_COOKIE_OPTIONS);
const redirectTo = event.url.searchParams.get('redirectTo') ?? '/mypage';
redirect(303, redirectTo);
},
}; セキュリティヘッダーを hooks.server.ts で一括設定する
CSRF 対策に加えて、ブラウザにセキュリティポリシーを伝えるHTTPヘッダーを設定します。handle の中でレスポンスに一括で付与するため、全ページに漏れなく適用されます。
X-Frame-Options
SAMEORIGIN を指定すると、自サイト以外の<iframe> への埋め込みを禁止する。クリックジャッキング攻撃(見えない iframe でユーザーに意図しないクリックをさせる)を防ぐ。 前シリーズ で追加済み。
X-Content-Type-Options
nosniff を指定すると、ブラウザが Content-Type を無視してコンテンツを推測するのを禁止する。悪意のあるファイルが別の形式に偽装されて実行されるリスクを防ぐ。
Referrer-Policy
strict-origin-when-cross-origin を指定すると、外部サイトへのリンクでは URL のパス情報を Referer ヘッダーに含めない。URL に含まれる情報(例:/mypage?token=xxx )が外部に漏洩するリスクを防ぐ。
Permissions-Policy
カメラ・マイク・位置情報など強力な API の使用を制限する。商品カタログアプリは使わないので全部無効化して攻撃面を最小化する。
Content-Security-Policy
スクリプト・スタイル・画像などリソースの読み込み元を制限する。XSS 攻撃で挿入されたスクリプトの実行を防ぐ最強の防御。ただし設定が複雑で、既存の動作を壊しやすいため慎重に設定する必要がある。
import type { Handle, HandleServerError, HandleFetch } 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 }) => {
// ── セッション検証(第2回と同じ) ──
const sessionId = event.cookies.get('session_id');
if (sessionId) {
event.locals.user = await getSession(sessionId);
if (!event.locals.user) event.cookies.delete('session_id', { path: '/' });
} else {
event.locals.user = null;
}
// ── 保護ルートガード ──
const isProtected = PROTECTED_ROUTES.some((r) => event.url.pathname.startsWith(r));
if (isProtected && !event.locals.user) {
redirect(303, `/login?redirectTo=${event.url.pathname}`);
}
// ── API エンドポイントの追加 CSRF チェック ──
const isMutatingApi = event.url.pathname.startsWith('/api/')
&& ['POST', 'PUT', 'DELETE'].includes(event.request.method);
if (isMutatingApi && event.request.headers.get('x-requested-with') !== 'XMLHttpRequest') {
return new Response('Forbidden', { status: 403 });
}
const response = await resolve(event);
// ── セキュリティヘッダーを一括設定 ──
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// CSP:本番では自サイトのオリジンのみ許可する(段階的に厳しくしていく)
response.headers.set('Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:"
);
return response;
};
// handleError・handleFetch は第2回と同じ
export const handleError: HandleServerError = ({ error, event, status, message }) => {
if (status !== 404) console.error(`[handleError] ${status} ${event.url.pathname}`, error);
return { message: status === 404 ? 'ページが見つかりません' : message };
};
export const handleFetch: HandleFetch = ({ request, fetch, event }) => {
const url = new URL(request.url);
if (url.origin === event.url.origin && url.pathname.startsWith('/api/')) {
const sessionId = event.cookies.get('session_id') ?? '';
return fetch(new Request(request, {
headers: { ...Object.fromEntries(request.headers), 'x-session-id': sessionId },
}));
}
return fetch(request);
};⚠️ ️ CSP は段階的に厳しくする
Content-Security-Policy は設定を間違えると CSS・JS・フォントなどが読み込めなくなります。今回は Google Fonts を使っているためfonts.googleapis.com とfonts.gstatic.com を許可しています。外部サービスを追加するたびに CSP の更新が必要です。まずContent-Security-Policy-Report-Only ヘッダーで違反をレポートモードで確認してから本番適用するのが安全です。
動作確認
ブラウザの開発者ツールでセキュリティ設定が反映されているか確認します。
$ curl -I http://localhost:5173/
HTTP/1.1 200 OK
content-type: text/html
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=()
content-security-policy: default-src 'self'; ...🎯 ブラウザでも確認する
Chrome の DevTools → Network タブ → ページのレスポンスを選択 → Headers タブで上記のセキュリティヘッダーが付いていることを確認してください。また Application タブ → Cookies でsession_id にHttpOnly フラグが付いていることも確認します。
トラブルシュート
❓ CSP を設定したら画面が崩れた・スクリプトが動かなくなった
CSP が外部リソースや inline スタイル・スクリプトをブロックしている可能性があります。まずContent-Security-Policy をContent-Security-Policy-Report-Only に変えてレポートモードで確認してください。DevTools の Console に「Refused to load...」のエラーが出るので、それに合わせて許可するオリジンを追加します。
❓ CSRF チェックで正当なリクエストまで 403 になる
X-Requested-With: XMLHttpRequest ヘッダーをクライアント側の fetch に付け忘れているか確認してください。またisMutatingApi の判定条件が広すぎて、保護対象でないエンドポイントまで巻き込んでいないか確認します。pathname.startsWith('/api/') の範囲が意図通りかチェックしてください。
❓ 本番環境で Cookie が送られず常にログアウト扱いになる
secure: true の設定で HTTPS 以外では Cookie が送られません。本番サーバーが HTTPS になっているか確認してください。またNODE_ENV 環境変数がproduction に設定されているか確認します。
第3回の完成コード一覧
src/
├─ hooks.server.ts UPDATE ← API CSRF チェック・セキュリティヘッダー追加
└─ lib/server/
└─ cookie.ts NEW ← Cookie オプション一元管理📄
src/lib/server/cookie.ts
Cookie オプションの一元管理
import type { CookieSerializeOptions } from 'cookie';
export const SESSION_COOKIE_OPTIONS: CookieSerializeOptions = {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24,
};📄
src/hooks.server.ts(完成形)
セッション検証・保護ルート・CSRF チェック・セキュリティヘッダー
import type { Handle, HandleServerError, HandleFetch } 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');
if (sessionId) {
event.locals.user = await getSession(sessionId);
if (!event.locals.user) event.cookies.delete('session_id', { path: '/' });
} else {
event.locals.user = 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 isMutatingApi = event.url.pathname.startsWith('/api/')
&& ['POST', 'PUT', 'DELETE'].includes(event.request.method);
if (isMutatingApi && event.request.headers.get('x-requested-with') !== 'XMLHttpRequest') {
return new Response('Forbidden', { status: 403 });
}
const response = await resolve(event);
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
response.headers.set('Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:"
);
return response;
};
export const handleError: HandleServerError = ({ error, event, status, message }) => {
if (status !== 404) console.error(`[handleError] ${status} ${event.url.pathname}`, error);
return { message: status === 404 ? 'ページが見つかりません' : message };
};
export const handleFetch: HandleFetch = ({ request, fetch, event }) => {
const url = new URL(request.url);
if (url.origin === event.url.origin && url.pathname.startsWith('/api/')) {
const sid = event.cookies.get('session_id') ?? '';
return fetch(new Request(request, {
headers: { ...Object.fromEntries(request.headers), 'x-session-id': sid },
}));
}
return fetch(request);
}; 第3回のまとめ
今回学んだこと
- CSRF は攻撃者が被害者のブラウザを使って意図しないリクエストを送る攻撃。Cookie の自動付与を悪用する
- SvelteKit の Form Actions は Origin チェックによりデフォルトで CSRF 対策済み。追加実装なしでシリーズBのフォームは保護されている
+server.tsへの fetch(非フォーム)はX-Requested-Withカスタムヘッダーを付与・検証することで追加対策できるsameSite: 'lax'は UX と CSRF 耐性のバランスが良い推奨設定。Cookie 設定をcookie.tsに一元管理することで設定漏れを防ぐsameSite: 'lax'はNODE_ENV === 'production'で切り替えることで開発環境でも Cookie が機能する- セキュリティヘッダーは
handleの中でレスポンスに一括付与することで全ページに漏れなく適用される。CSP は段階的に厳しくする
🎯 第3回のまとめ
CSRF 対策と Cookie セキュリティの強化が完了しました。シリーズCで実装した認証は bcrypt・DB セッション・CSRF 対策・セキュリティヘッダーが揃い、本番デプロイに向けた基盤が整いました。次回は GitHub OAuth によるソーシャルログイン を実装し、最後に本番デプロイ前のチェックリストで全体を締めくくります。
次回(第4回)
次の記事「 」でGitHub連携の説明を行います。
