JWTによるWeb認証の実装ガイド:基礎から実践まで
JWTを使ったWeb認証の実装方法を徹底解説。Access/Refreshトークン設計、XSS・CSRFを防ぐ安全な保存場所、Expressでの実践コードまで網羅。
JSON Web Token(JWT) は、現代のWeb開発においてデファクトスタンダードとなっている認証方式の1つです。Single Page Application(React, Vue, Svelte)からマイクロサービス、モバイルアプリのAPI認証に至るまで、幅広く活用されています。
しかし、JWTを正しく安全に実装することは多くのエンジニアがつまずくポイントでもあります。「トークンはどこに保存すべきか?」「セッションの有効期限切れはどう更新するか?」「XSSとCSRFの両方をどう防ぐか?」
本記事では、JWTを用いたWeb認証の基本概念から実践的なアーキテクチャまでをわかりやすく解説します。
1. 10歳でもわかる解説(ELI5):ロッカーの鍵 vs. 電子リストバンド#
JWTが誕生した理由を理解するために、遊園地の例えを使って従来のセッション認証と比較してみましょう。
従来の方式(セッション認証):ロッカーの番号札#
- 入園時に受付で番号札(Session ID)を渡されます。
- あなたの情報(名前、VIPランク、入園時刻)はすべて受付にある台帳(データベースやRedis)に記録されます。
- ジェットコースターに乗るたび、スタッフはインカムで受付に確認します。「102番の札を持っている人はVIPですか?」。
- 問題点: 10万人の客が一斉に50個のアトラクションに乗ると、受付への問い合わせがパンクしてしまいます。
現代の方式(JWT認証):電子署名入りリストバンド#
- 入園時に、情報があらかじめ印字されたスマートリストバンド(JWT)を渡されます。
- リストバンドには
氏名: Alice, ランク: VIP, 有効期限: 18:00と書かれています。 - さらに、運営本部の偽造不可能な電子署名(Digital Signature)が刻印されています。
- アトラクションに乗る際、スタッフはその場で電子署名の真正性を確認するだけでOKです。本部に問い合わせる必要はありません。
flowchart TD
subgraph Traditional["1. 従来のセッション認証 - Stateful"]
Client1["Client"] -->|Session IDを送信| Server1["App Server"]
Server1 -->|リクエスト毎にDB照会| DB["Database / Redis<br/>Session Store"]
end
subgraph JWTWay["2. JWT認証 - Stateless"]
Client2["Client"] -->|JWTトークンを送信| Server2["App Server"]
Server2 -->|その場で署名を自己検証| Server2
end
2. JWTを構成する3つの要素#
JWTはBase64URLでエンコードされた3つのパーツがドット(.)で連結された文字列です:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiQWxpY2UiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MDAwMDAwMDB9.4a5b6c...text- Header(ヘッダー): トークンのタイプ(
JWT)と暗号化アルゴリズム(HS256やRS256)を定義。 - Payload(ペイロード): 伝達するデータ(Claims)。ユーザーID(
sub)、権限(role)、有効期限(exp)など。 - Signature(署名): HeaderとPayloadをサーバーの秘密鍵で暗号署名したもの。
[!WARNING] Payloadは暗号化されていません! 単にBase64URLでエンコードされているだけなので、誰でも中身をデコードして読むことができます。パスワードやクレジットカード番号などの機密情報は絶対にPayloadに含めないでください。
3. 実務標準:デュアルトークン構成(Access & Refresh Token)#
単一のJWTだけを使用すると、有効期限を長くすれば漏洩リスクが高まり、短くすればユーザーが頻繁にログアウトされてしまいます。
Webアプリケーションにおけるベストプラクティスは2種類のトークンを併用する設計(Dual-Token Pattern)です:
- Access Token: 有効期限が極めて短い(10〜15分)。APIリクエストの認証に使用。
- Refresh Token: 有効期限が長い(7〜30日)。Access Tokenが切れた際に、再ログインなしで新しいAccess Tokenを取得するために使用。
flowchart TD
User["Client Browser"]
AuthServer["Auth API Server"]
ResourceServer["Protected Resource Server"]
User -->|1. POST /login - ログイン情報| AuthServer
AuthServer -->|2. Access TokenとRefresh Cookieを返却| User
User -->|3. Bearer Token付きでAPIリクエスト| ResourceServer
ResourceServer -->|4. データ返却 200 OK| User
User -->|5. 有効期限切れ 401 Unauthorized| ResourceServer
User -->|6. POST /refresh - HttpOnly Cookie| AuthServer
AuthServer -->|7. 新しいAccess Tokenを発行 - Token Rotation| User
4. トークンはどこに保存すべきか?(XSS vs. CSRF)#
フロントエンドにおける保存先の選定はセキュリティの最重要課題です:
| 保存場所 | XSSのリスク | CSRFのリスク | 総合評価 |
|---|---|---|---|
localStorage / sessionStorage | ❌ 危険(悪意あるJSから直接読み取られる) | ✅ 安全(ブラウザが自動送信しない) | 長期トークンの保存には非推奨 |
通常のCookie(HttpOnly なし) | ❌ 危険(document.cookie で窃取可能) | ❌ 危険(クロスサイト送信される) | 非常に危険 |
HttpOnly + Secure + SameSite Cookie | ✅ 安全(JavaScriptからアクセス不可) | ✅ 安全(SameSite がクロスサイト攻撃を遮断) | Refresh Tokenの推奨保存先 |
| メモリ(In-Memory JS変数 / State) | ✅ 安全(タブを閉じると消滅、永続化しない) | ✅ 安全(自動送信されない) | Access Tokenの推奨保存先 |
推奨される保存戦略:#
- Access Token: React / Vue 等のメモリ内ステートに保持する。
- Refresh Token:
HttpOnly+Secure+SameSite=Lax(またはStrict)Cookie に格納する。 - ユーザーがリロードした際は、バックグラウンドで
/auth/refreshを1回呼び出してメモリ上にAccess Tokenを復元する(Silent Refresh)。
5. Node.js / Express & TypeScript による実装例#
5.1. ログインとトークン発行#
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret';
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret';
export async function login(req: Request, res: Response) {
const { email, password } = req.body;
// 1. DB認証
const user = await validateUserCredentials(email, password);
if (!user) return res.status(401).json({ message: '認証情報が正しくありません' });
// 2. 短期Access Token発行 (15分)
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
// 3. 長期Refresh Token発行 (7日)
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
// 4. Refresh Tokenを安全なHttpOnly Cookieにセット
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7日間
});
// 5. レスポンスボディでAccess Tokenのみ返却
return res.json({ accessToken });
}typescript5.2. 認証ミドルウェア#
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'トークンが存在しないか不正です' });
}
const token = authHeader.split(' ')[1];
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!, (err, decodedUser) => {
if (err) {
return res.status(401).json({ message: 'トークンが無効または期限切れです' });
}
(req as any).user = decodedUser;
next();
});
}typescript5.3. トークン更新エンドポイント(Token Rotation)#
export async function refreshAccessToken(req: Request, res: Response) {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh Tokenが見つかりません' });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!, (err: any, decoded: any) => {
if (err) return res.status(403).json({ message: '無効なRefresh Tokenです' });
// 新しいAccess Tokenを発行
const newAccessToken = jwt.sign(
{ userId: decoded.userId, role: decoded.role },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: '15m' }
);
return res.json({ accessToken: newAccessToken });
});
}typescript6. やってはいけないセキュリティの落とし穴#
[!TIP] JWTセキュリティ・チェックリスト:
- 強固な秘密鍵: 最低でも256ビットのランダム文字列またはRSA/ECDSA公開鍵暗号を使用する。
"alg": "none"の拒否: トークン検証時に許可するアルゴリズムを明示的にホワイトリスト化する。- Audience (
aud) と Issuer (iss) の検証: 自社の認証サーバーが発行した正当なクライアント向けトークンであるかを確認する。- Token Rotation(トークンローテーション): Refresh Token使用時に古いトークンを無効化し、新しいRefresh Tokenを再発行する。
7. まとめ#
JWTを用いたWeb認証は、適切に設計すればステートレスで高いスケーラビリティを発揮します。「デュアルトークン構成」を採用し、Access Tokenはメモリ上、Refresh TokenはHttpOnly Cookieで管理することで、UXを損なわずに最高レベルのセキュリティを実現できます。