サイトロゴ
SvelteKit Created: 2026/06/03 Updated: 2026/06/09

SvelteKitでセッションをDB永続化する:有効期限・自動延長・クリーンアップ実装

SvelteKitのメモリ管理セッションをPrismaのDB管理へ移行し、有効期限、自動延長、期限切れクリーンアップまで実装します。

シリーズ:SvelteKit 認証完全ガイド

01

bcrypt・Prisma・サインアップ

02

セッションDB永続化・有効期限

03

CSRF 対策・Cookie セキュリティ

04

OAuth・GitHub・本番チェックリスト

第1回でユーザー情報を Prisma + SQLite の DB に移行し、パスワードのハッシュ化も実装しました。しかしセッション管理はまだサーバーメモリのMap のままです。 今回はセッションを Prisma のSession テーブルで管理するように移行し、 有効期限・自動延長・期限切れの定期クリーンアップ を実装します。最後に Redis との比較も紹介します。

💡 前提条件

第1回( SvelteKitでパスワード認証を本番対応する:Prisma導入・ハッシュ化・サインアップ実装 )で Prisma + SQLite をセットアップ済みで、prisma/schema.prisma User モデルが存在する状態で進めます。

Map セッションの問題点を整理する

前シリーズ( SvelteKit Hooks 完全ガイド )から使ってきたMap によるセッション管理には、本番運用で致命的な問題が3つあります。

💥 サーバー再起動でセッションが消える

デプロイのたびに全ユーザーが強制ログアウトされる。セキュリティアップデートのたびに影響が出る。

📈 メモリが際限なく増える

ユーザーがログアウトしなければセッションが蓄積し続ける。有効期限も管理できないため古いセッションが残り続ける。

🔀 複数サーバーで共有できない

ロードバランサーで複数サーバーに振り分けると、別サーバーにリクエストが届いたときにセッションが見つからずログアウト扱いになる。


セッションのライフサイクルを設計する

実装前にセッションがどう動くかを整理します。

ログイン

Session レコードを DB に作成。expiresAt を現在時刻 + 24時間にセット。セッションIDを Cookie に発行。

リクエスト

handle で Cookie のセッションID を読み取り DB を検索。expiresAt が未来なら有効。アクティブならexpiresAt を延長(スライディングウィンドウ)。

期限切れ

expiresAt が過去のセッションは無効扱い。Cookie も削除してログアウト状態にする。

ログアウト

Session レコードを DB から削除。Cookie も削除。


Session モデルをスキーマに追加する

prisma/schema.prisma Session モデルを追加します。User との関連(リレーション)も定義します。

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
  // User は複数の Session を持てる(1対多)
    sessions       Session[]
}

model Session {
  id        String   @id @default(cuid())
  userId    String                        // User への外部キー
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime                      // 有効期限
  createdAt DateTime @default(now())
}

❌ onDelete: Cascade とは

ユーザーが削除されたとき、そのユーザーのセッションも自動的に削除されます。この設定がないとユーザー削除時に「外部キー制約エラー」が発生します。セッションはユーザーに従属するデータなのでCascade が適切です。

スキーマを変更したらマイグレーションを実行します。

bash
$ npx prisma migrate dev --name add-session

✔ Generated Prisma Client (v6.x.x)

migrations/
  └─ 20240101000001_add_session/
       └─ migration.sql

session.ts を Prisma に差し替える

前シリーズ( SvelteKit Hooks 完全ガイド )のMap ベースのsession.ts を Prisma クエリに完全に置き換えます。関数のシグネチャ(引数と戻り値の型)は変えずに内部実装だけ差し替えるため、hooks.server.ts や各ページの変更は最小限です。

ts
import { db } from '$lib/server/db';

export type SessionUser = {
  id: string;
  name: string;
  email: string;
};

// セッションの有効期間:24時間
const SESSION_DURATION_MS = 1000 * 60 * 60 * 24;

// セッションを作成してIDを返す
export async function createSession(user: SessionUser): Promise<string> {
    const session = await db.session.create({
    data: {
      userId:    user.id,
            expiresAt: new Date(Date.now() + SESSION_DURATION_MS),
    },
    });
  return session.id;
}

// セッションIDからユーザー情報を取得。期限切れ・存在しなければ null
export async function getSession(sessionId: string): Promise<SessionUser | null> {
    const session = await db.session.findUnique({
    where: { id: sessionId },
    include: { user: true },   // User を JOIN して取得
    });

  // 存在しない場合
  if (!session) return null;

  // 有効期限チェック
    if (session.expiresAt < new Date()) {
      // 期限切れセッションをDBから削除
      await db.session.delete({ where: { id: sessionId } });
      return null;
    }

  // スライディングウィンドウ:アクティブなセッションの有効期限を延長
    await db.session.update({
    where: { id: sessionId },
        data:  { expiresAt: new Date(Date.now() + SESSION_DURATION_MS) },
    });

  return {
    id:    session.user.id,
    name:  session.user.name,
    email: session.user.email,
  };
}

// ログアウト:セッションをDBから削除
export async function deleteSession(sessionId: string): Promise<void> {
  await db.session.delete({
    where: { id: sessionId },
  }).catch(() => {}); // すでに削除済みでもエラーにしない
}

✨ スライディングウィンドウとは

リクエストのたびにexpiresAt を「現在時刻 + 24時間」に更新する方式です。アクティブに使っているユーザーはログインが延長され続け、24時間操作がなければ自動的にログアウトされます。「30日間ログインを維持」のような機能もこの仕組みで実現できます。


hooks.server.ts を非同期対応に更新する

getSession async になったため、hooks.server.ts handle await が必要になります。また期限切れセッションの Cookie も削除するよう更新します。

ts
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) {
    // await が必要になった(DB アクセスが非同期)
        event.locals.user = await getSession(sessionId);

    // getSession が null を返した = 期限切れ → Cookie も削除する
        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 response = await resolve(event);
  response.headers.set('X-Frame-Options', 'SAMEORIGIN');
  return response;
};

// handleError・handleFetch はシリーズBの実装をそのまま使用
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);
};

期限切れセッションの定期クリーンアップ

getSession の中でアクセスのあったセッションの期限切れは検出・削除できますが、 誰もアクセスしない古いセッション は DB に残り続けます。定期的にクリーンアップする仕組みが必要です。

クリーンアップ用 API エンドポイントを作る

SvelteKit の+server.ts で内部 API を作り、外部から定期実行(cron ジョブ)で叩く方式が実用的です。

ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';

export const DELETE: RequestHandler = async (event) => {
  // 本番では Authorization ヘッダーやシークレットキーで保護する
  const secret = event.request.headers.get('x-cron-secret');
  if (secret !== process.env.CRON_SECRET) {
    return json({ error: 'Unauthorized' }, { status: 401 });
  }

  // 現在時刻より前の expiresAt を持つセッションをすべて削除
    const { count } = await db.session.deleteMany({
      where: { expiresAt: { lt: new Date() } },
    });

  console.log(`[cleanup] Deleted ${count} expired sessions`);
  return json({ deleted: count });
};

✨ 定期実行の方法

Vercel や Cloudflare Workers には Cron Triggers(定期実行)機能があります。それ以外の環境では GitHub Actions のschedule トリガーや Linux のcrontab でこのエンドポイントを毎日叩くだけで機能します。CRON_SECRET .env に設定します。

全デバイスからログアウトする(セキュリティ機能)

パスワード変更時など「全デバイスのセッションを無効化したい」場面でも Prisma なら1行で対応できます。

ts
// src/lib/server/session.ts に追加
export async function deleteAllSessions(userId: string): Promise<void> {
  await db.session.deleteMany({
    where: { userId },
  });
}

Redis との比較 ― どんな場合に Redis を選ぶか

セッション管理のバックエンドとして Redis もよく使われます。SQLite(DB)と Redis のどちらを選ぶか、特性を比較します。

🗄 DB(Prisma + SQLite / PostgreSQL)

  • 追加インフラ不要。Prisma が既にある
  • ユーザーとセッションをJOINできる
  • 管理ツール(Prisma Studio)で確認しやすい
  • リクエストごとにDB読み書きが発生
  • 高トラフィックでは性能が課題になることも
  • 小〜中規模サービスに適している

🗄 DB(Prisma + SQLite / PostgreSQL)

  • セッションの読み書きが非常に高速
  • TTL(有効期限)を Redis 側で自動管理
  • 高トラフィックでスケールしやすい
  • 別途 Redis サーバーが必要
  • 障害時にセッションが消える可能性
  • 大規模・高トラフィックに適している

💡 このシリーズでは DB を選んだ理由

Redis は高速ですが別途インフラが必要です。このシリーズでは「追加インフラなしでローカルで完結する」ことを優先して Prisma + SQLite を選択しました。将来 Redis に移行する場合はsession.ts の内部実装を差し替えるだけで対応できるよう、関数インターフェースを統一してあります。

判断基準 DB を選ぶ Redis を選ぶ
同時ユーザー数 〜数千人規模 数万人〜
インフラの複雑さ シンプルにしたい 複雑でも性能を取りたい
セッション消失の許容 許容しない 許容できる(再ログインで対応)
既存の技術スタック Prisma を使っている Redis が既に使われている

トラブルシュート

❓ ログインしてもすぐにセッションが切れる

createSession expiresAt を正しく設定しているか確認してください。new Date(Date.now() + SESSION_DURATION_MS) の計算がnew Date(SESSION_DURATION_MS) (1970年基準の計算)になっていないか確認します。Prisma Studio で Session テーブルのexpiresAt の値を直接確認するのが早道です。

❓ deleteSession でエラーが出てログアウトできない

すでに期限切れで削除済みのセッションIDを削除しようとしている可能性があります。deleteSession .catch(() => {}) を追加してエラーを無視するか、deleteMany を使うとレコードが存在しなくてもエラーになりません。

❓ マイグレーション後に The table 'main'.'Session' does not exist が出る

npx prisma migrate dev を実行した後、npx prisma generate で Prisma Client を再生成する必要があります。開発サーバーを再起動してください。


第2回の完成コード一覧

text
prisma/
 └─ schema.prisma UPDATE ← Session モデル追加

src/
 ├─ hooks.server.ts UPDATE ← await・期限切れ Cookie 削除
 └─ lib/server/
        └─ session.ts UPDATE ← Map → Prisma に完全置き換え

src/routes/api/cleanup-sessions/
                 └─ +server.ts NEW ← 期限切れセッション削除 API

📄

prisma/schema.prisma(完成形)

User + Session モデル・1対多リレーション

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
  sessions       Session[]
}

model Session {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  createdAt DateTime @default(now())
}

📄

src/lib/server/session.ts(完成形)

Prisma 版・有効期限・スライディングウィンドウ・全セッション削除

ts
import { db } from '$lib/server/db';

export type SessionUser = { id: string; name: string; email: string };

const SESSION_DURATION_MS = 1000 * 60 * 60 * 24; // 24時間

export async function createSession(user: SessionUser): Promise<string> {
  const session = await db.session.create({
    data: { userId: user.id, expiresAt: new Date(Date.now() + SESSION_DURATION_MS) },
  });
  return session.id;
}

export async function getSession(sessionId: string): Promise<SessionUser | null> {
  const session = await db.session.findUnique({
    where: { id: sessionId },
    include: { user: true },
  });
  if (!session) return null;
  if (session.expiresAt < new Date()) {
    await db.session.delete({ where: { id: sessionId } });
    return null;
  }
  await db.session.update({
    where: { id: sessionId },
    data:  { expiresAt: new Date(Date.now() + SESSION_DURATION_MS) },
  });
  return { id: session.user.id, name: session.user.name, email: session.user.email };
}

export async function deleteSession(sessionId: string): Promise<void> {
  await db.session.delete({ where: { id: sessionId } }).catch(() => {});
}

export async function deleteAllSessions(userId: string): Promise<void> {
  await db.session.deleteMany({ where: { userId } });
}

第2回のまとめ

今回学んだこと

  • メモリMap セッションの問題点(再起動でリセット・メモリ増大・マルチサーバー非対応)を整理し、DB 永続化で解決した
  • Prisma のSession モデルにexpiresAt フィールドを持たせ、User との 1対多リレーションを定義した
  • getSession の中で期限切れチェック・スライディングウィンドウ延長を行う。hooks.server.ts で期限切れ時は Cookie も削除する
  • 期限切れセッションの定期クリーンアップはdeleteMany を使った専用 API エンドポイントを cron ジョブで定期実行する
  • deleteAllSessions(userId) で全デバイスの強制ログアウトが1行で実現できる
  • Redis は高速だが別途インフラが必要。小〜中規模なら DB セッションで十分

🎯 第2回のまとめ

セッション管理が本番レベルに近づきました。次回は CSRF 対策と Cookie セキュリティの強化 を実装します。SvelteKit の組み込み保護がどう機能しているかを理解しながら、sameSite secure ・CSP ヘッダーなどを追加して、さらに堅牢な認証基盤に仕上げます。