Web開発を学んでいると必ず出会う「JWT(JSON Web Token)」。
でも、いざ調べてみると…
- 「トークンベース認証って何?」
- 「セッション認証との違いがわからない」
- 「結局どうやって実装すればいいの?」
そんな疑問を持った経験はありませんか?
ぼくもその一人でした。JWTの説明を読んでも「ふんわりした内容ばかり」で、具体的にどう動くのかがイメージできませんでした。
そこでこの記事では、実際のコード例と具体的なフローを使って、JWTを「腑に落ちる」レベルまで解説します。
この記事で分かること
- JWTとは何か(5分で理解できる)
- アクセストークンとリフレッシュトークンの使い分け
- 実装の具体的なフロー(step by step)
- バックエンド・フロントエンドの実装例
- セキュリティ対策の注意点
JWTとは?一言で説明
JWT(JSON Web Token)は、「ユーザー情報を安全に運ぶパスポート」のようなものです。
従来のセッション認証では、サーバー側でユーザーのログイン状態を管理していました。
しかしJWTでは、ユーザー情報(userIdなど)をトークン自体に含めて、クライアントが持ち歩きます。
セッション認証 vs JWT認証
セッション認証の場合:
- ユーザーがログイン
- サーバーがセッションIDを発行してデータベースに保存
- クライアントがセッションIDを受け取り保存
- API呼び出し時にセッションIDを送信
- サーバーがデータベースでセッションIDを確認
JWT認証の場合:
- ユーザーがログイン
- サーバーがJWTトークンを発行(ユーザー情報を含む)
- クライアントがJWTを受け取り保存
- API呼び出し時にJWTを送信
- サーバーがJWTを検証(データベース不要)
JWTの構造を理解しよう
JWTは以下の3つの部分から構成されています:
[ヘッダー].[ペイロード].[署名]
実際のJWTはこのような文字列になります:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NSIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImlhdCI6MTY3MjUyNzYwMCwiZXhwIjoxNjcyNTMxMjAwfQ.signature_here
JWT.ioで実際に中身を見てみよう
上のJWT文字列をJWT.ioに貼り付けてデコードすると、以下のような構造が見えます:

このように、JWTの中身は簡単に見ることができます。
1. ヘッダー(Header)
トークンの基本情報を含む部分です。
{
"alg": "HS256", // 署名アルゴリズム
"typ": "JWT" // トークンタイプ
}
2. ペイロード(Payload)
実際のデータを含む部分です。ここにuserIdなどの情報を入れます。
{
"userId": "12345", // ユーザーID
"email": "user@example.com", // メールアドレス
"iat": 1672527600, // 発行時刻
"exp": 1672531200 // 有効期限
}
3. 署名(Signature)
改ざんを防ぐための署名です。サーバーの秘密鍵で作成されます。
重要: ヘッダーとペイロードは暗号化されていません。
jwt.ioの画像でもわかるように、誰でも内容を見ることができるため、パスワードなどの機密情報は絶対に含めてはいけません。
アクセストークンとリフレッシュトークンの仕組み
JWTには通常、2種類のトークンがあります。
これがJWT認証を理解する上で最も重要なポイントです。
アクセストークン(Access Token)
- 短い有効期限(15分〜1時間程度)
- 実際のAPI呼び出しに使用
- 頻繁に使われるため、盗まれるリスクが高い
リフレッシュトークン(Refresh Token)
- 長い有効期限(数日〜数週間)
- アクセストークンの再発行にのみ使用
- 使用頻度が低いため、比較的安全
なぜ2つのトークンが必要なのか
もしアクセストークンの有効期限を長くすると、盗まれた場合のリスクが高くなります。
逆に短くしすぎると、ユーザーが頻繁にログインし直す必要があります。
そこで、短期間のアクセストークンと長期間のリフレッシュトークンを組み合わせることで、セキュリティと利便性を両立させています。
JWT認証の具体的なフロー
実際にJWTがどのように使われるのか、step by stepで見てみましょう。
Step 1: ログイン
ユーザーがメールアドレスとパスワードでログインします。
// フロントエンド → サーバー
POST /api/auth/login
{
"email": "user@example.com",
"password": "password123"
}
Step 2: バックエンドでログインの検証
サーバーは受け取った認証情報をデータベースと照合し、正しいかどうかを確認します。
Step 3: バックエンドでJWTのトークンを発行
認証が成功すると、サーバーは2つのJWTを生成します:
アクセストークンのペイロード例:
{
"userId": "12345",
"email": "user@example.com",
"exp": 1672531200 // 1時間後
}
リフレッシュトークンのペイロード例:
{
"userId": "12345",
"exp": 1673135200 // 7日後
}
Step 4: フロントにJWTのトークンを保存
サーバーから返されたJWTをクライアント側で保存します。
// サーバー → フロントエンド
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
}
フロントエンドはこれらのトークンをlocalStorageやメモリに保存します。
この時点で、JWTにはuserIdなどのユーザー情報が含まれています。
Step 5: ユーザー情報などを取得する際にJWTをAPIのヘッダーに含める
ユーザー情報を取得したい場合、アクセストークンをAuthorizationヘッダーに含めて送信します:
GET /api/user/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Step 6: バックエンドでJWTトークンからuserIdを取得してユーザー情報を取得する
サーバーは以下の手順で処理します:
- JWTの検証:署名が正しく、有効期限内かをチェック
- userIdの取得:JWTのペイロードからuserIdを抽出
- ユーザー情報の取得:userIdを使ってデータベースからユーザー情報を取得
- レスポンス:ユーザー情報をクライアントに返す
この仕組みにより、サーバーはセッション情報を保持することなく、JWTだけでユーザーを識別できます。
トークンリフレッシュの自動取得フロー
アクセストークンの有効期限が切れた場合、リフレッシュトークンを使って自動で新しいアクセストークンを取得します。
自動リフレッシュの流れ
- API呼び出し:フロントエンドが期限切れのアクセストークンでAPI呼び出し
- 401エラー:サーバーが「Unauthorized」エラーを返す
- リフレッシュ要求:フロントエンドがリフレッシュトークンを使って新しいアクセストークンを要求
POST /api/auth/refresh
{
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
- 新トークン発行:サーバーが新しいアクセストークンを発行
- API再実行:フロントエンドが新しいトークンで元のAPIを再実行
この流れにより、ユーザーが意識することなく認証状態が維持されます。
実際のコード例
理論だけでなく、実際のコードも見てみましょう。
バックエンド例(Node.js + Express)
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
const SECRET_KEY = process.env.JWT_SECRET; // 環境変数から取得
app.use(express.json());
// ログインエンドポイント
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
try {
// データベースからユーザー情報を取得
const user = await findUserByEmail(email);
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// JWTトークンを生成
const accessToken = jwt.sign(
{
userId: user.id,
email: user.email
},
SECRET_KEY,
{ expiresIn: '1h' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
SECRET_KEY,
{ expiresIn: '7d' }
);
res.json({
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'Bearer',
expires_in: 3600
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
});
// JWT検証ミドルウェア
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')
; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Invalid token' });
}
req.user = decoded; // JWTから取得したユーザー情報をreqに追加
next();
});
};
// 保護されたエンドポイント
app.get('/api/user/profile', authenticateToken, async (req, res) => {
try {
// req.userにはJWTからデコードされたユーザー情報が入っている
const userId = req.user.userId;
// データベースからユーザー詳細情報を取得
const userProfile = await getUserProfile(userId);
res.json(userProfile);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user profile' });
}
});
// リフレッシュトークンエンドポイント
app.post('/api/auth/refresh', (req, res) => {
const { refresh_token } = req.body;
if (!refresh_token) {
return res.status(401).json({ error: 'Refresh token required' });
}
jwt.verify(refresh_token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// 新しいアクセストークンを発行
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
SECRET_KEY,
{ expiresIn: '1h' }
);
res.json({
access_token: newAccessToken,
token_type: 'Bearer',
expires_in: 3600
});
});
});
フロントエンド例(JavaScript)
class AuthService {
constructor() {
this.baseURL = 'http://localhost:3000/api';
}
// ログイン
async login(email, password) {
try {
const response = await fetch(`${this.baseURL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
// JWTをローカルストレージに保存
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
return data;
} catch (error) {
console.error('Login error:', error);
throw error;
}
}
// JWTを取得
getAccessToken() {
return localStorage.getItem('access_token');
}
// 認証が必要なAPIを呼び出す
async fetchWithAuth(url, options = {}) {
const token = this.getAccessToken();
const config = {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
};
let response = await fetch(url, config);
// トークンが期限切れの場合、リフレッシュを試行
if (response.status === 401) {
console.log('Token expired, refreshing...');
const refreshed = await this.refreshToken();
if (refreshed) {
// 新しいトークンで再実行
config.headers['Authorization'] = `Bearer ${this.getAccessToken()}`;
response = await fetch(url, config);
} else {
// リフレッシュ失敗時はログインページへリダイレクト
window.location.href = '/login';
return;
}
}
return response;
}
// トークンをリフレッシュ
async refreshToken() {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) return false;
try {
const response = await fetch(`${this.baseURL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: refreshToken })
});
if (!response.ok) return false;
const data = await response.json();
localStorage.setItem('access_token', data.access_token);
return true;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}
// ユーザープロフィール取得
async getUserProfile() {
const response = await this.fetchWithAuth(`${this.baseURL}/user/profile`);
if (!response.ok) {
throw new Error('Failed to fetch user profile');
}
return response.json();
}
// ログアウト
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
}
}
// 使用例
const authService = new AuthService();
// ログイン処理
document.getElementById('loginForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
try {
await authService.login(email, password);
console.log('Login successful!');
// ユーザープロフィールを取得
const profile = await authService.getUserProfile();
console.log('User profile:', profile);
// ダッシュボードへリダイレクト
window.location.href = '/dashboard';
} catch (error) {
console.error('Login failed:', error);
alert('ログインに失敗しました');
}
});
JWTのメリット・デメリット
メリット
ステートレス サーバー側でセッション情報を管理する必要がありません。
スケーラブル 複数のサーバー間でユーザー情報を共有しやすいです。
情報を含められる トークン内にユーザーの権限情報なども含められます。
クロスドメイン対応 異なるドメイン間でも認証情報を受け渡しできます。
デメリット
トークンサイズが大きい セッションIDと比べて情報量が多いため、通信量が増加します。
無効化が困難 一度発行したトークンを無効にするのが困難です。
セキュリティリスク 適切に保存・管理しないと盗まれるリスクがあります。
セキュリティ対策と注意点
1. 機密情報は含めない
jwt.ioの画像でも確認できるように、JWTの中身は誰でも見ることができます。
パスワードなどは絶対に含めないでください。
2. 適切な保存場所を選ぶ
上記のコード例ではlocalStorageを使用していますが、それぞれメリット・デメリットがあります:
localStorage
- メリット: ページリロードしても残る
- デメリット: XSS攻撃でJavaScriptから読み取られる可能性
httpOnly Cookie
- メリット: JavaScriptから読み取れない
- デメリット: CSRF攻撃に対策が必要、SameSite属性の設定が重要
メモリ
- メリット: XSS攻撃に強い
- デメリット: ページリロードで消える
本番環境では、httpOnly Cookieの使用を検討してください。
3. 適切な有効期限を設定
コード例のように、アクセストークンは短時間(1時間)、リフレッシュトークンは適切な期間(7日)に設定しましょう。
4. HTTPS必須
JWTは必ずHTTPS環境で使用してください。平文でのやり取りは絶対に避けましょう。
5. 秘密鍵の管理
// NG: ハードコーディングは危険
const SECRET_KEY = 'your-secret-key';
// OK: 環境変数から取得
const SECRET_KEY = process.env.JWT_SECRET;
本番環境では環境変数や専用のキー管理サービスを使用してください。
よくある質問
JWTとセッション認証、どちらを選ぶべき?
プロジェクトの要件によります:
- JWT: マイクロサービス、SPA、モバイルアプリ
- セッション: 従来のWebアプリケーション、シンプルな構成
JWTが盗まれた場合どうすれば?
以下の対策があります:
- 短い有効期限を設定
- リフレッシュトークンの仕組みを活用
- ブラックリスト機能の実装(推奨)
JWTの署名アルゴリズムは何を使うべき?
HS256(HMAC SHA-256)が一般的です。より高いセキュリティが必要な場合はRS256(RSA SHA-256)を検討してください。
まとめ
JWTは「ユーザー情報を安全に運ぶトークン」として理解すると分かりやすいです。
重要なポイント
- JWTにはuserIdなどの情報を含められる(jwt.ioで実際に確認できる)
- アクセストークン(短期)とリフレッシュトークン(長期)の2つを使い分ける
- 自動リフレッシュの仕組みでユーザーの利便性とセキュリティを両立
- API呼び出し時にAuthorizationヘッダーで送信する
- 機密情報は含めず、適切にセキュリティ対策を行う
この記事で紹介したコード例をベースに、実際のプロジェクトでJWT認証を実装してみてください。
特にjwt.ioでのデコード画像を見ることで、JWTの「中身が見える」という特徴がより理解できるはずです。