Next.js のセッションを“署名付きCookie + Redis”で設計する|失効・監査・多端末管理まで見据えた実務アーキテクチャ

はじめに

Cookie にユーザー情報(role や email など)をそのまま詰める“デモ実装”は、入門としては分かりやすい一方で、実務ではすぐ限界が来ます。理由はシンプルで、**「失効」と「監査」**ができないからです。

  • ログアウトしたのに、別端末のセッションが残っている
  • 漏洩が疑われるのに、特定ユーザーのセッションを強制的に切れない
  • いつ・どの端末からログインしたか追えない
  • 権限変更(admin→user)を即反映できない
  • Cookie が盗まれたとき、期限まで“有効”になってしまう

ここで効いてくるのが、**「Cookie はセッションIDだけ」**に寄せ、実体は **サーバー側ストア(Redis など)**に置く設計です。さらに、Cookie には **署名(または暗号化)**を施し、改ざん耐性を持たせます。

この記事では、Next.js(App Router)を前提に、次を狙った“筋の良い”セッション設計をまとめます。

  • 署名付きCookieで「改ざん」を防ぐ
  • **Redis(サーバー側ストア)**で「失効・一括ログアウト」を可能にする
  • セッションストアに 監査ログ(いつ/どこから/何を) を残す
  • 「実装を難しくしすぎない」最小構成で動かす

ハンズオンでは、ローカル Redis を使って ログイン→セッション作成→検証→失効→監査表示まで通します。


座学

1) まずゴール:Cookie を“身分証”にしない

Cookie にユーザー情報を載せる方式(JWT を Cookie に入れるのも含む)は、“ステートレス”に見えて便利ですが、運用で苦しくなりやすいです。
失効(revocation)が難しいからです。

実務で「失効したい」場面は頻出です。

  • パスワード変更 → 既存セッションは全て切りたい
  • 権限変更 → 即座に反映したい
  • 端末紛失 → その端末のセッションだけ切りたい
  • 不正ログイン疑い → 全ログアウト + 監査

そこで採用したいのが以下の形です。

  • Cookie:**署名付きセッションID(短い識別子)**だけ
  • Redis:**セッションの実体(ユーザーID、ロール、期限、端末情報)**を保持

これなら、Redis 側のレコードを消す/無効化するだけで、即失効できます。


2) 署名付きCookieが必要な理由

「Cookie に sessionId を入れるだけでいいのでは?」と思いがちですが、改ざんの観点で危険です。

  • もし攻撃者が sessionId=xxxx を好きに書き換えられると、他人のセッションに“当たり”を引く可能性が出る
  • 署名があると「その値はサーバーが発行したものか?」を検証できる
  • さらに httpOnly で JS から読めないようにし、XSS の被害を抑える

結論:Cookie は次を基本セットにします。

  • httpOnly: true(JS から読ませない)
  • secure: true(HTTPS でのみ送る)
  • sameSite: "lax"(CSRF を抑える基本)
  • 値は **署名(あるいは暗号化)**する

3) Redis セッションの設計要素(失効と監査のために)

Redis に置くセッション実体には、最低限これを持たせます。

  • userId:誰のセッションか
  • role:認可のため(ただし“都度DB参照”に寄せるなら最小化可能)
  • createdAt / lastSeenAt:監査と異常検知の入口
  • ip / ua(User-Agent)/ deviceId:端末識別や監査
  • expiresAt:期限
  • revokedAt(または revoked=true):失効済みフラグ

そして Redis のキー設計は、失効操作を考えて決めます。

  • sess:{sessionId} -> sessionData(TTL 付き)
  • user_sessions:{userId} -> Set(sessionId...)(ユーザーの全端末を追う)

こうしておくと、ユーザー単位の全ログアウトが簡単になります。
(Set から sessionId を列挙して sess:* を消すだけ)


4) 期限と更新:固定期限 + スライディング期限

実務だと「放置されているセッションを自然死させたい」が必ず出ます。代表的な方式は2つ。

  • 固定期限:ログインから 7日で必ず切れる
  • スライディング期限:アクセスがあれば期限を延長する(例:最後のアクセスから30分)

現場では、両方を組み合わせることが多いです。
「便利だけど永遠に切れない」にならないように、固定期限を上限に置きます。


5) CSRF と“Cookie 認証”の関係

Cookie は自動送信されるので、CSRF 対策が必要です。
ただし Next.js で “ページ閲覧” を守るだけなら、sameSite=lax でかなり軽減できます。

一方、危険なのは 状態変更系(POST/PUT/DELETE)
ここは以下のいずれかを採用します。

  • CSRF トークン(二重送信 cookie など)
  • Origin/Referer チェック
  • API を JSON 専用にして CORS を厳密化(外部から叩けないように)

この記事のハンズオンでは “最小構成” を優先し、
sameSite=lax を前提に、状態変更 API には Origin チェックを入れる方針にします。


ハンズオン

ここで作るもの

  • /login:ログイン(デモ)
  • /dashboard:ログイン必須(Redis からセッション取得)
  • /sessions:自分のセッション一覧(監査っぽい表示)
  • /logout:現在セッションだけ失効
  • /logout-all:全端末ログアウト(ユーザー配下のセッションを全失効)

技術要素

  • Cookie:sid(署名付き)
  • Redis:sess:{sid}(JSON + TTL)、user_sessions:{userId}(Set)
  • Next.js:Server Actions / Route Handlers で完結

0) Redis を用意(ローカル)

Docker がある前提で、Redis を起動します。

docker run --name redis-session -p 6379:6379 -d redis:7-alpine

1) Next.js プロジェクトを作成

npx create-next-app@latest nextjs-redis-session
cd nextjs-redis-session

必要ライブラリを追加します。

npm i redis

環境変数を用意します(例)。

.env.local

REDIS_URL=redis://localhost:6379
SESSION_SECRET=replace-with-long-random-string
SESSION_TTL_SECONDS=1800
SESSION_ABSOLUTE_TTL_SECONDS=604800

2) Redis クライアント

app/_lib/redis.ts

import { createClient } from "redis";const url = process.env.REDIS_URL!;
export const redis = createClient({ url });let connected = false;
export async function getRedis() {
if (!connected) {
await redis.connect();
connected = true;
}
return redis;
}

3) 署名付きCookie(HMAC)を作る

ここは “本番の最低ライン” です。Cookie 値を sid.signature の形にします。
署名が合わないものは無効扱い。

app/_lib/signedCookie.ts

import crypto from "crypto";const secret = process.env.SESSION_SECRET!;function hmac(input: string) {
return crypto.createHmac("sha256", secret).update(input).digest("base64url");
}export function signSid(sid: string) {
const sig = hmac(sid);
return `${sid}.${sig}`;
}export function verifySignedSid(value: string) {
const parts = value.split(".");
if (parts.length !== 2) return null; const [sid, sig] = parts;
const expected = hmac(sid); // timing-safe compare
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length) return null;
if (!crypto.timingSafeEqual(a, b)) return null; return sid;
}

4) セッションストア:Redis に保存・失効・監査

app/_lib/sessionStore.ts

import crypto from "crypto";
import { getRedis } from "./redis";type Role = "user" | "admin";export type Session = {
sid: string;
userId: string;
email: string;
role: Role; createdAt: number;
lastSeenAt: number; ip: string;
ua: string; revokedAt: number | null;
absoluteExpiresAt: number; // 上限
};const ttl = Number(process.env.SESSION_TTL_SECONDS ?? "1800"); // 30m
const absTtl = Number(process.env.SESSION_ABSOLUTE_TTL_SECONDS ?? "604800"); // 7dfunction sessKey(sid: string) {
return `sess:${sid}`;
}
function userSessionsKey(userId: string) {
return `user_sessions:${userId}`;
}export async function createSession(params: {
userId: string;
email: string;
role: Role;
ip: string;
ua: string;
}) {
const r = await getRedis();
const sid = crypto.randomBytes(24).toString("base64url");
const now = Date.now(); const session: Session = {
sid,
userId: params.userId,
email: params.email,
role: params.role,
createdAt: now,
lastSeenAt: now,
ip: params.ip,
ua: params.ua,
revokedAt: null,
absoluteExpiresAt: now + absTtl * 1000,
}; await r.set(sessKey(sid), JSON.stringify(session), { EX: ttl });
await r.sAdd(userSessionsKey(params.userId), sid);
// セット側にも期限を付けたい場合は別途 expire(ここでは簡略化)
return session;
}export async function getSession(sid: string) {
const r = await getRedis();
const raw = await r.get(sessKey(sid));
if (!raw) return null; const session = JSON.parse(raw) as Session;
if (session.revokedAt) return null; // 絶対期限チェック
if (Date.now() > session.absoluteExpiresAt) {
await revokeSession(sid, session.userId);
return null;
} return session;
}export async function touchSession(sid: string) {
const r = await getRedis();
const session = await getSession(sid);
if (!session) return null; session.lastSeenAt = Date.now(); // スライディング更新:TTL延長 + lastSeen更新
await r.set(sessKey(sid), JSON.stringify(session), { EX: ttl });
return session;
}export async function revokeSession(sid: string, userId?: string) {
const r = await getRedis();
const raw = await r.get(sessKey(sid));
if (raw) {
const session = JSON.parse(raw) as Session;
session.revokedAt = Date.now();
// 失効済みとして短めに残す(監査のため)
await r.set(sessKey(sid), JSON.stringify(session), { EX: 300 }); // 5分
await r.sRem(userSessionsKey(userId ?? session.userId), sid);
return;
}
if (userId) await r.sRem(userSessionsKey(userId), sid);
}export async function revokeAllSessions(userId: string) {
const r = await getRedis();
const sids = await r.sMembers(userSessionsKey(userId));
await Promise.all(sids.map((sid) => revokeSession(sid, userId)));
}export async function listUserSessions(userId: string) {
const r = await getRedis();
const sids = await r.sMembers(userSessionsKey(userId));
const sessions = await Promise.all(sids.map((sid) => r.get(sessKey(sid))));
return sessions
.filter(Boolean)
.map((raw) => JSON.parse(raw!) as Session)
.sort((a, b) => b.lastSeenAt - a.lastSeenAt);
}

ここで重要なのは「失効しても短時間は残す」ことです。
完全に消すだけだと、監査や不正調査の入口がなくなります。


5) Cookie からセッションを取り出す認証ユーティリティ

app/_lib/authServer.ts

import { cookies, headers } from "next/headers";
import { verifySignedSid, signSid } from "./signedCookie";
import { createSession, getSession, touchSession, revokeSession, revokeAllSessions, listUserSessions } from "./sessionStore";const COOKIE_NAME = "sid";export async function loginWithDemoUser(email: string) {
// デモユーザー
const user =
email === "admin@example.com"
? { userId: "a1", role: "admin" as const }
: { userId: "u1", role: "user" as const }; const h = headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const ua = h.get("user-agent") ?? "unknown"; const session = await createSession({ userId: user.userId, email, role: user.role, ip, ua }); const signed = signSid(session.sid);
cookies().set(COOKIE_NAME, signed, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
}); return session;
}export async function getCurrentSession() {
const value = cookies().get(COOKIE_NAME)?.value;
if (!value) return null; const sid = verifySignedSid(value);
if (!sid) return null; // lastSeen 更新(スライディング)
return await touchSession(sid);
}export async function logoutCurrentSession() {
const value = cookies().get(COOKIE_NAME)?.value;
cookies().set(COOKIE_NAME, "", { path: "/", maxAge: 0 }); if (!value) return;
const sid = verifySignedSid(value);
if (!sid) return; const sess = await getSession(sid);
if (!sess) return; await revokeSession(sid, sess.userId);
}export async function logoutAll() {
const sess = await getCurrentSession();
cookies().set(COOKIE_NAME, "", { path: "/", maxAge: 0 });
if (!sess) return;
await revokeAllSessions(sess.userId);
}export async function getMySessions() {
const sess = await getCurrentSession();
if (!sess) return null;
return await listUserSessions(sess.userId);
}

6) ログインページ(Server Action)

app/login/page.tsx

import { redirect } from "next/navigation";
import { loginWithDemoUser } from "../_lib/authServer";async function loginAction(formData: FormData) {
"use server";
const email = String(formData.get("email") ?? "");
if (!email) throw new Error("email required"); await loginWithDemoUser(email);
redirect("/dashboard");
}export default function LoginPage() {
return (
<main style={{ padding: 24 }}>
<h1>Login(Redisセッション)</h1>
<p>admin@example.com または user@example.com を入力。</p> <form action={loginAction} style={{ display: "grid", gap: 12, maxWidth: 420 }}>
<input name="email" placeholder="email" />
<button type="submit">ログイン</button>
</form>
</main>
);
}

7) ダッシュボード(ログイン必須 + RBAC 表示)

app/dashboard/page.tsx

import { redirect } from "next/navigation";
import { getCurrentSession } from "../_lib/authServer";export default async function DashboardPage() {
const sess = await getCurrentSession();
if (!sess) redirect("/login"); return (
<main style={{ padding: 24 }}>
<h1>Dashboard</h1>
<p>email: {sess.email}</p>
<p>role: {sess.role}</p>
<p>lastSeen: {new Date(sess.lastSeenAt).toLocaleString()}</p> <ul>
<li><a href="/sessions">セッション一覧(監査)</a></li>
<li><a href="/logout">ログアウト(この端末)</a></li>
<li><a href="/logout-all">全端末ログアウト</a></li>
</ul> {sess.role === "admin" ? (
<p>admin機能を表示できます(RBAC入口)。</p>
) : (
<p>一般ユーザー権限です。</p>
)}
</main>
);
}

8) 監査:セッション一覧を表示

app/sessions/page.tsx

import { redirect } from "next/navigation";
import { getMySessions } from "../_lib/authServer";export default async function SessionsPage() {
const sessions = await getMySessions();
if (!sessions) redirect("/login"); return (
<main style={{ padding: 24 }}>
<h1>My Sessions(監査ビュー)</h1>
<p>どの端末からログインしているかの入口になります。</p> <ul>
{sessions.map((s) => (
<li key={s.sid} style={{ marginBottom: 12 }}>
<div>sid: {s.sid.slice(0, 8)}…</div>
<div>ip: {s.ip}</div>
<div>ua: {s.ua}</div>
<div>created: {new Date(s.createdAt).toLocaleString()}</div>
<div>lastSeen: {new Date(s.lastSeenAt).toLocaleString()}</div>
</li>
))}
</ul> <a href="/dashboard">戻る</a>
</main>
);
}

9) ログアウト(現在セッションのみ失効)

app/logout/page.tsx

import { redirect } from "next/navigation";
import { logoutCurrentSession } from "../_lib/authServer";async function action() {
"use server";
await logoutCurrentSession();
redirect("/login");
}export default function LogoutPage() {
return (
<main style={{ padding: 24 }}>
<h1>Logout</h1>
<form action={action}>
<button type="submit">この端末をログアウト</button>
</form>
</main>
);
}

10) 全端末ログアウト(失効の威力)

app/logout-all/page.tsx

import { redirect } from "next/navigation";
import { logoutAll } from "../_lib/authServer";async function action() {
"use server";
await logoutAll();
redirect("/login");
}export default function LogoutAllPage() {
return (
<main style={{ padding: 24 }}>
<h1>Logout All</h1>
<p>ユーザー配下のセッションを全て失効させます。</p>
<form action={action}>
<button type="submit">全端末ログアウト</button>
</form>
</main>
);
}

動作確認の手順

  1. user@example.com でログイン → /dashboard
  2. /sessions を開いて監査情報(lastSeen / ua / ip)を見る
  3. 別ブラウザ(またはシークレット)で同じユーザーでログインしてセッションが増えることを確認
  4. /logout-all を叩く → もう一方のブラウザもログアウト状態になる(Redis 側で失効できている)

これが「Cookie だけでは難しい」運用要件を満たす入口です。


まとめ

署名付きCookie + Redis の構成に寄せると、認証は “機能” から “運用できる仕組み” に進化します。

  • Cookie は セッションIDのみにして、改ざんを 署名で防ぐ
  • セッション実体は Redis に保持し、削除/無効化で 即失効できる
  • user_sessions:{userId} のような補助インデックスを持つと
    • 全端末ログアウト
    • 端末ごとの失効
    • 監査ビュー
      が現実的になる
  • 失効済みセッションを短時間残すだけでも、調査の入口になる
  • lastSeen / ip / ua を持たせると、不正検知や運用の地盤ができる

実務では「ログインできる」より、「事故ったときに止められる」「後から追える」ほうが大事です。
この設計はまさにそのための土台になります。


投稿日

カテゴリー:

投稿者:

タグ:

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です