表現の改善にAIを活用しています。
あらかじめご了承ください。
はじめに
このプロジェクトでは、ユーザ登録機能を実装しています。
Webアプリケーションでは、ログイン後の状態をどのように維持し、認証が必要なAPIと公開APIをどう安全に分けるかが重要です。
本記事では、このプロジェクトで採用しているユーザセッション管理の実装について紹介します。
認証の流れや実際の実装内容を交えながら、個人開発のWebアプリでどのようにセッション管理を実現したのかを説明していきます。
なぜセッション管理が必要か
WebアプリケーションのバックエンドAPIは、大きく以下の2種類に分類できます。
- ログイン状態でのみアクセス可能なAPI
- ユーザプロフィールの取得
- パスワード変更
- ログイン不要の公開API
- ログイン
- パスワードリセット
これらを適切に制御するためには、「ユーザがログイン状態であるか」を管理・判定する仕組みが必要です。
また、このプロジェクトでは一般ユーザとは別に管理者アカウントも用意しているため、「ログインしているか」だけでなく「どの権限を持っているか」を判定する必要もあります。
たとえば、管理者向けAPIに一般ユーザがアクセスできてしまうと、アプリの運用に大きな影響を与える可能性があります。
そのため、認証に加えて認可(権限チェック)も含めた仕組みが必要になります。
本プロジェクトでは、この仕組みをユーザセッション管理として実装します。
採用構成の概要
本プロジェクトでは、access token = JWT / refresh token = opaque 方式によるユーザセッション管理を採用しています。
この方式は、モダンなWeb/API認証で広く採用されている実務的な標準パターンでもあります。
| access token(JWT) | refresh token(opaque) | |
|---|---|---|
| 寿命 | 短命 | 長命 |
| 用途 | APIアクセス時の本人確認・権限確認 | access token の再発行 |
| 特徴 | payload に情報を持つ自己完結型 | 中身に意味を持たないランダム文字列 |
| 管理 | サーバー側DB不要(署名検証のみ) | サーバー側DBで照合・失効管理 |
「access token は自己完結・高速検証、refresh token はサーバー管理で安全に更新」という役割分担が明確です。
このプロジェクトでは、バックエンド側で主に以下を担当しています。
- access token の発行と検証
- refresh token の発行、保存、検証、失効管理
- 認証必須APIでのユーザセッション確認
- 管理者・一般ユーザの権限チェック
認証フロー図
本プロジェクトにおける、認証必須 API アクセスの流れは次のようになります。
以降では、それぞれの実装を順に紹介していきます。
access token = JWT
access token の生成には JWT を使用します。
export function generateAccessToken(userId, role) {
return jwt.sign(
// payload
{ userId, role },
// 署名用秘密鍵
process.env.JWT_ACCESS_SECRET,
// 有効期限
{ expiresIn: TOKEN_EXPIRATION.ACCESS_TOKEN }
);
}
access token には userId と role を埋め込んでいます。
これによりサーバー側は、毎回データベースへアクセスしなくても、jwt.verify() による署名検証と有効期限確認だけで、
- 誰のリクエストか
- どの権限を持つか
を即座に判定できます。
JWT を利用する大きなメリットは、この「ステートレスな認証」が実現できる点にあります。
JWT は便利ですが、「JWT を使っているから安全」というわけではありません。
- 署名用秘密鍵を安全に管理する
- payload は暗号化されないため、機密情報を含めない
- 有効期限を適切に設定する
といった基本設計が非常に重要です。
サインインまたはサインアップ成功時に access token を生成し、クライアントへ返却します。
router.post('/signin',
...
async (req, res) => {
...
const accessToken = generateAccessToken(userId, role);
return res.json({ success: true, accessToken });
}
);
このプロジェクトでは HTTPS 通信を前提としています。
HTTP 通信ではレスポンス内容を盗聴される可能性があるため、access token を安全に扱えません。
認証必須 API では middleware で access token を検証します。
export const verifySession = async (req, res, next) => {
try {
const accessToken = getAccessToken(req);
try {
const payload = jwt.verify(accessToken, process.env.JWT_ACCESS_SECRET);
req.user = { userId: payload.userId, role: payload.role };
return next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: { code: ERROR_CODES.TOKEN_EXPIRED }
});
}
return res.status(401).json({
success: false,
error: { code: ERROR_CODES.INVALID_SESSION }
});
}
} catch (err) {
...
}
};
verifySession では、リクエストに含まれる access token を検証し、成功した場合は req.user に userId と role を格納して後続の処理へ渡します。
access token の有効期限切れは、異常ではなく想定内の状態として扱っています。
そのため、期限切れの場合は TOKEN_EXPIRED を返し、クライアント側で refresh API を呼び出して再発行できるようにしています。
一方で、署名不正や改ざんされた token は INVALID_SESSION として扱います。
また、このプロジェクトでは一般ユーザと管理者を区別しているため、認証に加えて認可(権限チェック)も必要です。
verifySession で復元した role をもとに、後続の middleware でアクセス可能な権限を制御します。
export function verifyRole(requiredRoles) {
const roles = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles];
return async (req, res, next) => {
const { userId, role } = req.user || {};
try {
if (!userId) {
return res.status(401).json({
success: false,
error: { code: ERROR_CODES.UNAUTHORIZED }
});
}
if (!roles.includes(role)) {
return res.status(403).json({
success: false,
error: { code: ERROR_CODES.INSUFFICIENT_ROLE }
});
}
next();
} catch (err) {
...
}
}
}
verifyRole は、verifySession によって設定された req.user の role を確認し、必要な権限を持つユーザだけに処理を許可する middleware です。
たとえば管理者専用APIでは、verifySession の後に verifyRole を挟むことで、ログイン済みであることに加えて管理者権限を持っていることまで確認できます。
router.get('/admin/example',
verifySession,
verifyRole(ROLES.ADMIN),
async (req, res) => {
...
}
);
このように、このプロジェクトでは access token に含めた role を利用して、認証と認可を分離して実装しています。
user_sessions テーブル
本プロジェクトでは、refresh token をサーバー側で管理するために user_sessions テーブルを用意しています。
CREATE TABLE `user_sessions` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`refresh_token_hash` varchar(255) NOT NULL,
`expires_at` datetime NOT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=145 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
このテーブルでは、各ユーザのセッション情報を管理します。
-
user_idどのユーザのセッションかを表します。 -
refresh_token_hashrefresh token のハッシュ値を保存します。トークンそのものは機密情報なので、平文では保存しません。 -
expires_atそのセッションの有効期限です。期限を過ぎたセッションは無効として扱います。
このように、refresh token をデータベースで管理することで、サーバー側でセッションの失効や期限管理を行えるようにしています。
refresh token = opaque
本プロジェクトでは、refresh token に opaque token を採用しています。
ここでいう opaque とは、「トークン自体には意味を持たせず、単なるランダム文字列として扱う」という意味です。
refresh token を opaque にすると、次のメリットがあります。
- 情報を含まないので安全
- 失効・ローテーションをサーバー側で管理しやすい
識別子付き opaque token
実運用では、完全に「意味のないランダム文字列」ではなく、ある程度サーバー側で扱いやすい構造を持たせることもあります。
例えばこのプロジェクトでは、「識別子付き opaque token」を採用しています。
「識別子付き opaque token」と「完全 opaque token」の比較
| 項目 | 識別子付きopaque token (sessionId.rawToken) | 完全 opaque token |
|---|---|---|
| DB検索 |
sessionIdを使った主キー検索が可能 |
トークンハッシュによる検索が必要 |
| セキュリティ |
rawToken が秘密情報。sessionId 単体では認証に利用できない |
同等 |
| トークン盗用検知 |
sessionId でレコードを取得し、ハッシュ不一致を検知できる |
ハッシュ検索で同様に検知可能 |
| 実装複雑度 | トークンの分解処理が必要 | シンプル |
本プロジェクトでは「識別子付きopaque token方式」を採用しているが、小〜中規模のサービスでは、完全 Opaque Token 方式でも十分なケースが多いです。
refresh token の生成処理は次のようになります。
export async function generateRefreshToken(userId, connection) {
const [result] = await connection.query(`
INSERT INTO user_sessions (user_id, refresh_token_hash, expires_at)
VALUES (?, '', DATE_ADD(NOW(), INTERVAL 30 DAY))
`,[userId]
);
const sessionId = result.insertId;
const rawToken = crypto.randomBytes(64).toString('hex');
const hash = hashToken(rawToken);
await connection.query(`
UPDATE user_sessions SET refresh_token_hash = ? WHERE id = ?
`,[hash, sessionId]
);
return `${sessionId}.${rawToken}`;
}
まず user_sessions にセッション情報のレコードを作成し、そのレコード ID を sessionId として取得します。
その後、ランダムな文字列を生成して rawToken とし、そのハッシュ値を refresh_token_hash に保存します。
rawToken は機密情報なので、そのままデータベースには保存しません。
レコード ID(sessionId)と rawToken を結合し、最終的な refresh token として返しています。
つまり、クライアントに渡す refresh token は以下の形式になります。
<sessionId>.<rawToken>
この形式にしておくことで、サーバー側は受け取った refresh token から sessionId を取り出し、対応するセッション情報を効率よく参照できます。
サインインまたはサインアップ成功時に refresh token を生成し、Cookie に保存します。
router.post('/signin',
...
async (req, res) => {
...
const refreshToken = await generateRefreshToken(userId, connection);
setRefreshTokenCookie(res, refreshToken);
return res.json({ success: true, accessToken });
}
);
refresh token は HttpOnly Cookie に保存しています。
function getRefreshTokenCookieBaseOptions() {
const isProduction = process.env.NODE_ENV === 'production';
return {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? 'none' : 'strict',
path: '/'
};
}
export function setRefreshTokenCookie(res, refreshToken) {
res.cookie('refresh_token', refreshToken, {
...getRefreshTokenCookieBaseOptions(),
maxAge: COOKIE_MAX_AGE.REFRESH_TOKEN
});
}
refresh token を保存する Cookie には httpOnly: true を付けています。
これにより、JavaScript から Cookie を直接参照できなくなり、token の窃取リスクを軽減できます。
secure は本番環境のみ true にしており、HTTPS 通信でのみ Cookie を送信するようにしています。
また、sameSite は本番環境では none、開発環境では strict に切り替えています。
このように環境ごとに Cookie の設定を調整することで、開発時の扱いやすさと本番運用時の安全性を両立しています。
refresh token の検証では、まず token を <sessionId>.<rawToken> 形式として解析し、sessionId に対応するセッション情報を取得します。
export async function verifyRefreshToken(refreshToken) {
// <sessionId>.<rawToken> 形式でなければ即失敗
...
const { sessionId, rawToken } = parsedToken;
const [rows] = await pool.query(`
SELECT id, user_id, refresh_token_hash, expires_at
FROM user_sessions
WHERE id = ?
`,[sessionId]
);
if (rows.length === 0) {
return {
ok: false,
error: { code: ERROR_CODES.INVALID_SESSION }
};
}
const expiresAt = rows[0].expires_at;
const now = new Date();
if (expiresAt < now) {
return {
ok: false,
error: { code: ERROR_CODES.SESSION_EXPIRED },
invalidSessionId: sessionId
};
}
// 保存済み hash と rawToken を比較
// 一致しない場合は失敗
...
return { ok: true, session };
}
セッションレコードが存在しない場合は無効、期限切れなら失効として扱います。
さらに、保存済みのハッシュ値と rawToken を比較し、一致した場合のみ有効な refresh token として受け入れます。
このように、refresh token はサーバー側で状態を持って管理しているため、
- セッション単位で失効できる
- 期限切れを厳密に管理できる
- 必要に応じてローテーションしやすい
という利点があります。
refresh API / signout
access token は短寿命で運用しているため、期限切れになった場合は refresh token を使って再発行する仕組みが必要になります。
認証必須 API で access token が期限切れの場合、サーバーは TOKEN_EXPIRED を返します。
クライアント側はこの結果を受けて refresh API を呼び出し、新しい access token を取得します。
refresh API では、Cookie に保存された refresh token を取り出して検証し、新しい access token を発行します。
router.post('/refresh', async (req, res) => {
const refreshToken = getRefreshToken(req);
const result = await verifyRefreshToken(refreshToken);
// refresh token のローテーション(任意)
...
const newAccessToken = generateAccessToken(userId, role);
return res.json({ success: true, accessToken: newAccessToken });
});
この仕組みにより、access token が期限切れになっても、ユーザは毎回サインインし直すことなくセッションを継続できます。
サインアウト時には refresh token に対応するセッション情報を削除し、以降は access token を再発行できない状態にします。
router.post('/signout', verifySession, async (req, res) => {
try {
const refreshToken = getRefreshToken(req);
if (refreshToken) {
const result = await verifyRefreshToken(refreshToken);
const sessionIdToDelete = result.ok ? result.session.id : result.invalidSessionId;
if (sessionIdToDelete) {
await pool.query('DELETE FROM user_sessions WHERE id = ?', [sessionIdToDelete]);
}
clearRefreshToken(res);
}
return res.json({ success: true });
} catch (err) {
...
}
});
refresh token が有効な場合はそのセッションを削除し、期限切れなどで無効になっていた場合でも invalidSessionId が取得できれば対応するセッション情報を削除します。
そのうえで Cookie も削除することで、クライアント側・サーバー側の両方でセッションを終了します。
最後に
今回は、ユーザセッション管理(バックエンド側)の実装について紹介しました。
個人開発向けのシンプルな構成ではありますが、セッション管理を実装するうえで必要な流れは押さえられたと思います。
実務では、セキュリティ要件や運用面を考慮して、より複雑な構成や仕組みが採用されるケースも多いでしょう。
最後までお読みいただきありがとうございました。