iron-session
認証ライブラリ
iron-session
概要
iron-sessionは、Next.jsアプリケーション専用に設計された軽量で安全なセッション管理ライブラリです。2025年現在、Next.js App RouterとPages Routerの両方で完全にサポートされ、暗号化されたCookieベースの状態永続化によりステートレスなセッション管理を実現します。JSONWの証明書トークンやデータベースセッションストレージを必要とせず、@hapi/ironアルゴリズムを使用してセッションデータを安全に暗号化・署名し、Cookieとして保存します。TypeScriptとの統合により型安全性を提供し、サーバーサイドレンダリング、API Routes、ミドルウェアでシームレスに動作します。
詳細
iron-sessionは、Cookieベースの暗号化セッション管理を提供するNext.js専用ライブラリです。主な特徴:
- 暗号化セッション: @hapi/ironアルゴリズムによる強力な暗号化とHMAC署名
- Next.js最適化: App Router、Pages Router、API Routes、ミドルウェアとの完全統合
- TypeScript統合: 型安全なセッションオブジェクトと自動補完機能
- ステートレス: データベースやRedisを必要としない完全ステートレス設計
- セキュリティ: CSRF保護、セッションローテーション、TTL設定
- 軽量: 最小限の依存関係でバンドルサイズを抑制
- 柔軟性: カスタムCookie設定、セッション構造、暗号化オプション
メリット・デメリット
メリット
- データベース不要のステートレス設計により運用コストとインフラ負荷を大幅削減
- 強力な暗号化によりセッションデータの盗聴・改ざんを完全防止
- Next.jsとの深い統合によりサーバーサイドレンダリングで高パフォーマンス
- TypeScript完全対応で開発時の型安全性と保守性を確保
- 軽量設計によりページ読み込み速度とBundle サイズを最適化
- シンプルなAPI設計で学習コストが低く導入が容易
デメリット
- Next.js専用のため他のフレームワークでは使用不可
- Cookieサイズ制限(4KB)により大量のセッションデータ保存は困難
- セッション無効化の即座反映が困難(暗号化Cookieのため)
- 複数サーバー間でのセッション共有時は同一暗号化キーが必要
- 高度なセッション分析機能は別途実装が必要
参考ページ
- GitHub - vvo/iron-session
- iron-session ドキュメント
- Next.js Authentication Examples
- @hapi/iron Documentation
書き方の例
基本セットアップとセッション設定
// lib/session.ts - セッション設定とヘルパー関数
import { getIronSession, IronSession } from 'iron-session';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
// セッションデータの型定義
export interface SessionData {
userId?: string;
username?: string;
email?: string;
role?: 'admin' | 'user' | 'guest';
isLoggedIn: boolean;
loginAt?: number;
expiresAt?: number;
}
// セッション設定
export const sessionOptions = {
password: process.env.SESSION_SECRET_KEY!,
cookieName: 'myapp-session',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7日間
sameSite: 'lax' as const,
path: '/',
},
ttl: 60 * 60 * 24 * 7, // 7日間(秒単位)
};
// デフォルトセッションデータ
export const defaultSession: SessionData = {
isLoggedIn: false,
};
// サーバーサイドでセッションを取得
export async function getSession(): Promise<IronSession<SessionData>> {
const session = await getIronSession<SessionData>(cookies(), sessionOptions);
// セッションが空の場合はデフォルト値を設定
if (!session.isLoggedIn) {
Object.assign(session, defaultSession);
}
return session;
}
// API Routes用セッション取得
export async function getSessionFromRequest(
request: NextRequest
): Promise<IronSession<SessionData>> {
const response = new NextResponse();
const session = await getIronSession<SessionData>(
request,
response,
sessionOptions
);
if (!session.isLoggedIn) {
Object.assign(session, defaultSession);
}
return session;
}
// セッション有効期限チェック
export function isSessionValid(session: SessionData): boolean {
if (!session.isLoggedIn || !session.expiresAt) {
return false;
}
return Date.now() < session.expiresAt;
}
// セッションのリフレッシュ
export async function refreshSession(session: IronSession<SessionData>): Promise<void> {
if (session.isLoggedIn) {
session.loginAt = Date.now();
session.expiresAt = Date.now() + (sessionOptions.ttl * 1000);
await session.save();
}
}
App Router でのログイン・ログアウト実装
// app/api/auth/login/route.ts - ログインAPI
import { NextRequest, NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
import { sessionOptions } from '@/lib/session';
import bcrypt from 'bcryptjs';
interface LoginRequest {
email: string;
password: string;
rememberMe?: boolean;
}
export async function POST(request: NextRequest) {
try {
const { email, password, rememberMe }: LoginRequest = await request.json();
// バリデーション
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
);
}
// ユーザー認証(実際にはデータベースから取得)
const user = await authenticateUser(email, password);
if (!user) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// セッション作成
const response = new NextResponse(
JSON.stringify({ success: true, user: { id: user.id, email: user.email, role: user.role } })
);
const session = await getIronSession(request, response, {
...sessionOptions,
cookieOptions: {
...sessionOptions.cookieOptions,
maxAge: rememberMe ? 60 * 60 * 24 * 30 : sessionOptions.cookieOptions.maxAge, // 30日 or 7日
},
});
// セッションデータ設定
session.userId = user.id;
session.username = user.username;
session.email = user.email;
session.role = user.role;
session.isLoggedIn = true;
session.loginAt = Date.now();
session.expiresAt = Date.now() + (rememberMe ? 30 * 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000);
await session.save();
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// ユーザー認証関数(ダミー実装)
async function authenticateUser(email: string, password: string) {
// 実際にはデータベースからユーザーを取得
const users = [
{
id: '1',
username: 'admin',
email: '[email protected]',
password: await bcrypt.hash('password123', 10),
role: 'admin' as const,
},
{
id: '2',
username: 'user',
email: '[email protected]',
password: await bcrypt.hash('userpass', 10),
role: 'user' as const,
},
];
const user = users.find(u => u.email === email);
if (!user) return null;
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) return null;
return {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
};
}
// app/api/auth/logout/route.ts - ログアウトAPI
export async function POST(request: NextRequest) {
try {
const response = new NextResponse(
JSON.stringify({ success: true, message: 'Logged out successfully' })
);
const session = await getIronSession(request, response, sessionOptions);
// セッションクリア
session.destroy();
return response;
} catch (error) {
console.error('Logout error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// app/api/auth/me/route.ts - 現在のユーザー情報取得
export async function GET(request: NextRequest) {
try {
const response = new NextResponse();
const session = await getIronSession(request, response, sessionOptions);
if (!session.isLoggedIn || !isSessionValid(session)) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
// セッション延長
await refreshSession(session);
return NextResponse.json({
user: {
userId: session.userId,
username: session.username,
email: session.email,
role: session.role,
loginAt: session.loginAt,
},
});
} catch (error) {
console.error('Get user error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
ログインフォームとクライアントサイドの実装
// components/LoginForm.tsx - ログインフォームコンポーネント
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface LoginFormProps {
onSuccess?: () => void;
}
export default function LoginForm({ onSuccess }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password,
rememberMe,
}),
});
const data = await response.json();
if (response.ok) {
router.refresh(); // サーバーコンポーネントを再レンダリング
onSuccess?.();
router.push('/dashboard');
} else {
setError(data.error || 'Login failed');
}
} catch (err) {
setError('Network error occurred');
console.error('Login error:', err);
} finally {
setIsLoading(false);
}
};
return (
<div className="max-w-md mx-auto bg-white p-8 rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6 text-center">ログイン</h2>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
メールアドレス
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="[email protected]"
/>
</div>
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
パスワード
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="••••••••"
/>
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">
ログイン状態を保持する(30日間)
</span>
</label>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? '処理中...' : 'ログイン'}
</button>
</form>
<div className="mt-4 text-center">
<p className="text-sm text-gray-600">
テストアカウント:<br />
管理者: [email protected] / password123<br />
一般ユーザー: [email protected] / userpass
</p>
</div>
</div>
);
}
// hooks/useAuth.ts - 認証状態管理カスタムフック
import { useState, useEffect } from 'react';
interface User {
userId: string;
username: string;
email: string;
role: 'admin' | 'user' | 'guest';
loginAt: number;
}
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
}
export function useAuth() {
const [state, setState] = useState<AuthState>({
user: null,
isLoading: true,
error: null,
});
const fetchUser = async () => {
try {
setState(prev => ({ ...prev, isLoading: true, error: null }));
const response = await fetch('/api/auth/me', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
setState({
user: data.user,
isLoading: false,
error: null,
});
} else {
setState({
user: null,
isLoading: false,
error: null,
});
}
} catch (error) {
setState({
user: null,
isLoading: false,
error: 'Failed to fetch user',
});
}
};
const logout = async () => {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
setState({
user: null,
isLoading: false,
error: null,
});
} catch (error) {
console.error('Logout error:', error);
}
};
useEffect(() => {
fetchUser();
}, []);
return {
...state,
refetch: fetchUser,
logout,
};
}
サーバーコンポーネントでのセッション認証
// app/dashboard/page.tsx - 保護されたダッシュボードページ
import { redirect } from 'next/navigation';
import { getSession, isSessionValid } from '@/lib/session';
import UserProfile from '@/components/UserProfile';
import LogoutButton from '@/components/LogoutButton';
export default async function DashboardPage() {
const session = await getSession();
// セッション認証チェック
if (!session.isLoggedIn || !isSessionValid(session)) {
redirect('/login');
}
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">ダッシュボード</h1>
<LogoutButton />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* ユーザー情報カード */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">ユーザー情報</h2>
<div className="space-y-2">
<p><strong>ユーザー名:</strong> {session.username}</p>
<p><strong>メール:</strong> {session.email}</p>
<p><strong>役割:</strong> {session.role}</p>
<p><strong>ログイン日時:</strong> {
session.loginAt ? new Date(session.loginAt).toLocaleString('ja-JP') : '不明'
}</p>
</div>
</div>
{/* セッション情報カード */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">セッション情報</h2>
<div className="space-y-2">
<p><strong>ユーザーID:</strong> {session.userId}</p>
<p><strong>ログイン状態:</strong> {session.isLoggedIn ? '有効' : '無効'}</p>
<p><strong>有効期限:</strong> {
session.expiresAt ? new Date(session.expiresAt).toLocaleString('ja-JP') : '設定なし'
}</p>
</div>
</div>
{/* 機能カード */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">利用可能機能</h2>
<div className="space-y-2">
{session.role === 'admin' && (
<p className="text-green-600">• 管理者機能</p>
)}
<p className="text-blue-600">• プロフィール管理</p>
<p className="text-blue-600">• データ閲覧</p>
<p className="text-blue-600">• 設定変更</p>
</div>
</div>
</div>
<UserProfile session={session} />
</div>
);
}
// components/UserProfile.tsx - ユーザープロフィール管理
import { SessionData } from '@/lib/session';
interface UserProfileProps {
session: SessionData;
}
export default function UserProfile({ session }: UserProfileProps) {
return (
<div className="mt-8 bg-white p-6 rounded-lg shadow">
<h2 className="text-2xl font-semibold mb-6">プロフィール設定</h2>
<form className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
表示名
</label>
<input
type="text"
defaultValue={session.username}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
メールアドレス
</label>
<input
type="email"
defaultValue={session.email}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
プロフィール更新
</button>
</form>
</div>
);
}
// components/LogoutButton.tsx - ログアウトボタン
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function LogoutButton() {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleLogout = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
router.push('/login');
router.refresh();
}
} catch (error) {
console.error('Logout error:', error);
} finally {
setIsLoading(false);
}
};
return (
<button
onClick={handleLogout}
disabled={isLoading}
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50"
>
{isLoading ? 'ログアウト中...' : 'ログアウト'}
</button>
);
}
ミドルウェアでの認証ガード
// middleware.ts - Next.js ミドルウェアでの認証
import { NextRequest, NextResponse } from 'next/server';
import { getIronSession } from 'iron-session';
import { sessionOptions, SessionData, isSessionValid } from '@/lib/session';
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
try {
const session = await getIronSession<SessionData>(
request,
response,
sessionOptions
);
const { pathname } = request.nextUrl;
// 保護されたルートの定義
const protectedRoutes = ['/dashboard', '/profile', '/admin'];
const adminRoutes = ['/admin'];
const authRoutes = ['/login', '/register'];
const isProtectedRoute = protectedRoutes.some(route =>
pathname.startsWith(route)
);
const isAdminRoute = adminRoutes.some(route =>
pathname.startsWith(route)
);
const isAuthRoute = authRoutes.some(route =>
pathname.startsWith(route)
);
// ログイン済みユーザーが認証ページにアクセスした場合
if (isAuthRoute && session.isLoggedIn && isSessionValid(session)) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// 未認証ユーザーが保護されたページにアクセスした場合
if (isProtectedRoute && (!session.isLoggedIn || !isSessionValid(session))) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
// 管理者権限が必要なページへのアクセス制御
if (isAdminRoute && session.role !== 'admin') {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// API ルートの認証チェック
if (pathname.startsWith('/api/') && !pathname.startsWith('/api/auth/')) {
if (!session.isLoggedIn || !isSessionValid(session)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
}
return response;
} catch (error) {
console.error('Middleware error:', error);
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|public).*)',
],
};
// lib/auth-guards.ts - 認証ガード関数
import { redirect } from 'next/navigation';
import { getSession, isSessionValid } from './session';
export async function requireAuth() {
const session = await getSession();
if (!session.isLoggedIn || !isSessionValid(session)) {
redirect('/login');
}
return session;
}
export async function requireAdmin() {
const session = await requireAuth();
if (session.role !== 'admin') {
redirect('/dashboard');
}
return session;
}
export async function requireRole(role: 'admin' | 'user') {
const session = await requireAuth();
if (session.role !== role && session.role !== 'admin') {
redirect('/dashboard');
}
return session;
}
// 使用例: app/admin/page.tsx
export default async function AdminPage() {
const session = await requireAdmin();
return (
<div>
<h1>管理者ページ</h1>
<p>ようこそ、{session.username}さん</p>
</div>
);
}
セッション管理の高度な機能
// lib/advanced-session.ts - 高度なセッション管理機能
import { getIronSession, IronSession } from 'iron-session';
import { cookies } from 'next/headers';
import { sessionOptions, SessionData } from './session';
// セッションアクティビティログ
interface SessionActivity {
timestamp: number;
action: 'login' | 'logout' | 'access' | 'refresh';
ip?: string;
userAgent?: string;
path?: string;
}
// 拡張セッションデータ
export interface ExtendedSessionData extends SessionData {
activities?: SessionActivity[];
lastAccess?: number;
deviceFingerprint?: string;
csrfToken?: string;
}
// セッションアクティビティ記録
export async function logActivity(
session: IronSession<ExtendedSessionData>,
action: SessionActivity['action'],
metadata?: Partial<SessionActivity>
) {
if (!session.activities) {
session.activities = [];
}
const activity: SessionActivity = {
timestamp: Date.now(),
action,
...metadata,
};
session.activities.push(activity);
// 最新の10件のみ保持
if (session.activities.length > 10) {
session.activities = session.activities.slice(-10);
}
session.lastAccess = Date.now();
await session.save();
}
// CSRF トークン生成・検証
export function generateCSRFToken(): string {
return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}
export async function setCSRFToken(session: IronSession<ExtendedSessionData>) {
session.csrfToken = generateCSRFToken();
await session.save();
return session.csrfToken;
}
export function validateCSRFToken(
session: ExtendedSessionData,
token: string
): boolean {
return session.csrfToken === token;
}
// セッション分析
export function analyzeSession(session: ExtendedSessionData) {
const now = Date.now();
const loginDuration = session.loginAt ? now - session.loginAt : 0;
const lastAccessDuration = session.lastAccess ? now - session.lastAccess : 0;
return {
loginDuration: Math.floor(loginDuration / 1000 / 60), // 分
lastAccessMinutes: Math.floor(lastAccessDuration / 1000 / 60), // 分
activityCount: session.activities?.length || 0,
recentActions: session.activities?.slice(-5) || [],
sessionHealth: lastAccessDuration < 30 * 60 * 1000 ? 'active' : 'idle', // 30分
};
}
// セッションクリーンアップ
export async function cleanupExpiredSession(
session: IronSession<ExtendedSessionData>
) {
if (!isSessionValid(session)) {
await logActivity(session, 'logout', {
path: '/cleanup',
userAgent: 'system'
});
session.destroy();
return true;
}
return false;
}
// セキュリティ監査
export function auditSession(session: ExtendedSessionData) {
const now = Date.now();
const issues: string[] = [];
// 長時間ログイン警告
if (session.loginAt && (now - session.loginAt) > 24 * 60 * 60 * 1000) {
issues.push('Long session duration detected');
}
// 非アクティブセッション警告
if (session.lastAccess && (now - session.lastAccess) > 2 * 60 * 60 * 1000) {
issues.push('Inactive session detected');
}
// 異常なアクティビティパターン
const recentActivities = session.activities?.filter(
a => now - a.timestamp < 60 * 60 * 1000
) || [];
if (recentActivities.length > 50) {
issues.push('Unusual activity pattern detected');
}
return {
issues,
riskLevel: issues.length > 0 ? 'medium' : 'low',
recommendations: issues.map(issue => {
switch (issue) {
case 'Long session duration detected':
return 'Consider implementing session rotation';
case 'Inactive session detected':
return 'Consider automatic logout for inactive sessions';
case 'Unusual activity pattern detected':
return 'Review recent activity for potential security issues';
default:
return 'Review session security settings';
}
}),
};
}
// app/api/session/info/route.ts - セッション情報API
export async function GET() {
try {
const session = await getSession() as IronSession<ExtendedSessionData>;
if (!session.isLoggedIn) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
const analysis = analyzeSession(session);
const audit = auditSession(session);
// アクティビティログを記録
await logActivity(session, 'access', {
path: '/api/session/info'
});
return NextResponse.json({
session: {
userId: session.userId,
username: session.username,
role: session.role,
loginAt: session.loginAt,
lastAccess: session.lastAccess,
},
analysis,
audit,
activities: session.activities?.slice(-5) || [],
});
} catch (error) {
console.error('Session info error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}