JSON Web Token が人気なのは、API が誰も呼び出さずにトークンを確認できるからです。署名を検証し、クレームを読めば終わりです。しかしこの性質は、JWT の問題の大半の原因でもあります。照会できないトークンは、取り消せないトークンです。チームはノート PC が盗まれたときや、従業員が退職したときに、初めてこのことに気づきます。
この記事では、アクセストークン、リフレッシュトークン、シーケンス図を使って JWT 認証フロー全体を順に説明します。そのうえで、すべての API に必要な検証、ブラウザーでのトークンの保存場所、リフレッシュトークンのローテーション、鍵のローテーション、そして現実的な失効の選択肢を扱います。
JWT とは何か、何ではないか
JWT(RFC 7519)は、ドットで区切られた 3 つの base64url 部分からなる、コンパクトで URL セーフな文字列です。ヘッダー、クレームのペイロード、署名です。デコードすると、アクセストークンは次のようになります。
{ "alg": "RS256", "kid": "2026-10", "typ": "at+jwt" }
{
"iss": "https://auth.example.com",
"sub": "user_8412",
"aud": "orders-api",
"scope": "orders:read orders:write",
"iat": 1793606400,
"exp": 1793607300,
"jti": "a3f1c9e2"
}
誤解の大半は、次の 3 点から生まれます。
- 署名付きの JWT は暗号化されていません。 トークンを持っていれば誰でもペイロードを読めます。保持者に見られて困る秘密や個人データは入れないでください。
- JWT はプロトコルではなくフォーマットです。 クライアントがトークンを取得する方法は別のところ、通常は OAuth 2.0 と OpenID Connect で定義されています。その部分は OAuth 2.0 フロー図で扱っています。
- フローに出てくるトークンがすべて JWT とは限りません。 アクセストークンは JWT であることが多いですが、リフレッシュトークンは認可サーバーだけが照会できる不透明なランダム文字列であることがよくあります。
どのトークンにも出てくる登録済みクレームは次のとおりです。
| クレーム | 意味 | 確認するのは |
|---|---|---|
iss |
Issuer。トークンに署名した認可サーバー | API |
sub |
Subject。通常はユーザー ID | API(認可のため) |
aud |
Audience。トークンの宛先となる API | API |
exp、nbf、iat |
有効期限、有効開始時刻、発行時刻(エポックからの秒数) | API |
jti |
一意のトークン ID。拒否リストに便利 | API またはゲートウェイ |
フローを順に追う
- サインイン。 ユーザーは認可サーバーで認証します。通常は PKCE 付きの OAuth 認可コードフローを使います。
- トークンの応答。 認可サーバーは、短命なアクセストークン(JWT、たとえば 15 分)と長命なリフレッシュトークンを返します。
- API の呼び出し。 アプリはリクエストのたびに
Authorization: Bearer <access token>を送ります。 - ローカルでの検証。 API は、JWKS エンドポイントから一度取得してキャッシュした認可サーバーの公開鍵で署名を検証し、続いてクレームを確認します。リクエストごとに認可サーバーを呼び出すことはありません。
- 期限切れ。 15 分後、API は
401 Unauthorizedを返します。 - リフレッシュ。 アプリはリフレッシュトークンを認可サーバーのトークンエンドポイントに送り、新しいアクセストークンと、ローテーションを使う場合は新しいリフレッシュトークンを受け取ります。
- 再試行。 アプリは、失敗したリクエストを新しいアクセストークンで繰り返します。
ステップ 3 と 4 こそ、チームが JWT を選ぶ理由です。数千件の API リクエストがあっても、共有のセッションストアは要りません。ステップ 5 と 6 はその代償であり、セキュリティ上の作業の大半はここにあります。
トークンの検証:チェックリスト
JWT を受け付けるすべての API は、同じ確認をこの順序で行うべきです。
- 想定するアルゴリズムだけを許可します。
algヘッダーを鵜呑みにしてはいけません。RFC 8725 は 2 つの古典的な攻撃を説明しています。algをnoneにする攻撃と、RS256 のトークンを HS256 に書き換え、素朴なライブラリに公開鍵を HMAC の秘密鍵として使わせて検証させる攻撃です。 - キャッシュした JWKS から
kidで鍵を選び、未知のkidを見たらセットを再取得します。ただし短い間隔で 1 回までにします。 - 署名を検証します。
issとaudを確認します。 別の API 向けに発行されたトークンは、ここで失敗しなければなりません。expとnbfを、60 秒程度の小さな時計の許容幅を持たせて確認します。- トークンの種類を確認します。 RFC 9068 のアクセストークンは
typ: at+jwtを持っており、ID トークンがアクセストークンとして使い回されるのを防ぎます。 - そのうえで認可します。 スコープ、ロール、リソースの所有権です。
Node.js の jose ライブラリを使えば、リストの大半は 1 回の呼び出しで済みます。
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'))
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'orders-api',
algorithms: ['RS256'],
typ: 'at+jwt',
clockTolerance: 60,
})
return payload // scopes and sub are checked by the caller
}
アクセストークンの有効期間はどれくらいにすべきか
有効期間とは、盗まれたトークンが使え、しかも取り消せない時間枠のことです。短いほど安全ですが、リフレッシュの通信は増えます。8 時間の勤務時間中ずっと操作しているユーザーの場合は次のとおりです。
| アクセストークンの有効期間 | 盗まれたときの露出時間 | ユーザー 1 人あたりの 1 日のリフレッシュ回数 |
|---|---|---|
| 5 分 | 5 分 | 96 |
| 15 分 | 15 分 | 32 |
| 1 時間 | 1 時間 | 8 |
| 24 時間 | 24 時間 | 1 |
ほとんどのチームは、アクセストークンを 5〜15 分に落ち着かせます。1 日有効なアクセストークンは、失効させられないセッション Cookie と同じです。
リフレッシュトークンのローテーション
リフレッシュトークンは数日から数週間有効なので、漏えいすればアクセストークンの漏えいより深刻です。OAuth セキュリティの現在のベストプラクティスである RFC 9700 は、ブラウザーアプリやモバイルアプリのようなパブリッククライアントが持つリフレッシュトークンに対して、次の 2 つのうちいずれかの保護を求めています。
- 送信者制約付きのリフレッシュトークン。 相互 TLS や DPoP によって、クライアントが持つ鍵に結び付けます。
- ローテーション。 リフレッシュのたびに新しいリフレッシュトークンを返して古いものを無効にし、認可サーバーはそのチェーンを記録します。攻撃者と正規のクライアントが同じトークンを使えば、どちらかが無効化済みのトークンを提示することになり、サーバーは有効なリフレッシュトークンを失効させます。これで両者のチェーンが終わります。
ローテーションを使えば、盗まれたリフレッシュトークンは数週間続く問題から、正規のクライアントが次にリフレッシュした時点で終わる問題に変わります。代償として、ユーザーは再サインインが必要になります。またトークンエンドポイントは、2 つのタブが同時にリフレッシュしても再利用検知が誤作動しないように対処する必要があり、通常は短い猶予期間を設けます。
ブラウザーでのトークンの保存場所
| 保存場所 | JavaScript から読めるか | 主なリスク | 補足 |
|---|---|---|---|
localStorage |
読める | どんな XSS でもトークンを盗める | 再読み込み後も残る。リフレッシュトークンには使わない |
| メモリのみ | ページが生きている間は読める | セッション中の XSS | 再読み込みで消える。起動時にリフレッシュする |
HttpOnly Cookie |
読めない | CSRF | Secure、SameSite と、状態を変えるリクエストには CSRF トークンを使う |
| Backend for frontend | 読めない。トークンはサーバーに残る | サーバー側のセッション管理 | ブラウザーはセッション Cookie だけを持つ |
ブラウザーベースのアプリケーション向け OAuth に関する IETF のドラフトは、backend for frontend パターンを、トークンを JavaScript から完全に遠ざける選択肢として説明しています。トークンをブラウザーに置くなら、アクセストークンはメモリに、リフレッシュトークンはトークンエンドポイントにスコープを限定した HttpOnly Cookie に置いてください。
失効:実際に機能する方法
JWT の署名を取り消すことはできません。できるのは API に受け付けをやめさせることだけで、どの方法もステートレスさを少しずつ手放すことになります。
| 方法 | 仕組み | コスト |
|---|---|---|
| 短い有効期限 | トークンは数分で自然に失効する | なし。露出時間は残る |
| リフレッシュトークンの失効 | 次のリフレッシュが失敗し、次の期限切れでアクセスが終わる | すでに状態を持つトークンエンドポイントでの 1 回の照会 |
jti による拒否リスト |
ゲートウェイがリストにある ID を exp まで拒否する |
リクエストごとに確認する共有キャッシュ |
| ユーザー単位の基準時刻 | そのユーザーに保存した時刻より前に発行されたトークンを拒否する | ユーザーごとに 1 回の照会。キャッシュ可能 |
| イントロスペクション | API がトークンごとに認可サーバーに問い合わせる(RFC 7662) | ネットワーク呼び出し。高リスクのエンドポイントだけに使う |
実用的な組み合わせは次のとおりです。アクセストークンは 15 分以下にし、ローテーションするリフレッシュトークンをログアウト時とパスワード変更時に失効させ、まれな「今すぐすべての場所からサインアウト」には、ユーザー単位の基準時刻を使います。
署名鍵のローテーション
鍵は漏れ、アルゴリズムは古くなります。ローテーションは初日から計画しておきましょう。新しい公開鍵は、それで署名を始める 1 日ほど前に JWKS で公開し、すべての API のキャッシュに行き渡らせます。署名を新しい kid に切り替えます。古い鍵は、それで署名した最後のトークンの期限が切れるまで JWKS に残し、その後に削除します。未知の kid を見たら再取得する API なら、緊急のローテーションも再起動なしで乗り切れます。
JWT フロー図
ダイアグラムの内容
- Browser app
- Auth server
- Orders API
- access token expired
- 1. Sign in (OAuth code flow + PKCE)
- 2. Access token (JWT, 15 min) + refresh token
- 3. GET /orders, Authorization: Bearer JWT
- GET /.well-known/jwks.json (once, then cached)
- public keys
- 4. 200 OK
- 5. GET /orders (expired JWT)
- 6. 401 Unauthorized
- 7. POST /token (refresh token)
- 8. New token pair, old refresh token invalidated
- 9. Retry GET /orders with new JWT
- 10. 200 OK
図の読み方は次のとおりです。
- 3 本のライフライン。 左がブラウザーアプリ、中央が認可サーバー、右が注文 API です。時間は下に向かって流れます。
- ログインとトークンの応答が一番上にあります。アプリはアクセストークンとリフレッシュトークンを受け取ります。
- 正常系:API への Bearer リクエストとその応答です。API はキャッシュした JWKS の鍵でトークンを検証し、認可サーバーを一切呼び出しません。
- opt フラグメントは期限切れの場合を扱います。
401、リフレッシュ、古いリフレッシュトークンを無効化した新しいトークンの組、そして再試行したリクエストです。 - Markdown ブロックには、デコードしたトークンと検証項目を載せています。
レビュアーが確認できる認証フローを描くには
- すべての矢印で、すべてのトークンに名前を付けます。 種類と有効期間も添えて、「アクセストークン(JWT、15 分)」のように書きます。どのトークンがどこへ行くのかが曖昧だと、本物のバグが隠れます。
- 検証がどこで行われるかを示します。 署名を検証するライフラインにノートを置き、その鍵がどこから来るのかを書きます。
- 失敗の経路を描きます。 期限切れ、リフレッシュトークンの再利用、ログアウトはフラグメントに描きます。インシデントの大半はそこから始まります。
- 秘密は図に載せません。 プレースホルダーの値を使いましょう。図は思っている以上に広く共有されます。
ライフラインとフラグメントについてはシーケンス図の例で詳しく扱っています。より広いアクセスモデルの中でトークンの確認がどこに位置するかは、ゼロトラストアーキテクチャ図を参照してください。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- シーケンスパックの ライフライン ノードを 3 つ追加します。Browser app、Auth server、Orders API です。
- メッセージを上から下へ、ライフライン間のエッジとして描きます。ログイン、トークンの応答、Bearer リクエストとその応答です。それぞれに運ぶトークンのラベルを付けます。
- 期限切れの経路を囲むように、演算子を
optにしたシーケンスパックの 複合フラグメント を追加します。401、リフレッシュ要求、新しいトークンの組、再試行です。 - デコードしたトークンと検証チェックリストを書いた基本パックの Markdown ブロックを追加します。
この例で使うダイアグラムノードは 5 個で、Free プランの範囲に収まります。
よくある質問
JWT は暗号化されていますか? 署名付きの JWT は暗号化されていません。ペイロードは base64url でエンコードされているだけで、トークンを持つ人なら誰でも読めます。暗号化されたトークンには JSON Web Encryption がありますが、ほとんどのアクセストークンは署名だけです。
JWT を localStorage に保存すべきですか? 長期間有効なものには使わないでください。クロスサイトスクリプティングのバグがあれば、localStorage は読まれてしまいます。アクセストークンはメモリに、リフレッシュトークンは HttpOnly Cookie に置くか、トークンを backend for frontend に置いてください。
JWT がステートレスなら、ユーザーをどうやってログアウトさせればよいですか? リフレッシュトークンを失効させて新しいアクセストークンが発行されないようにし、アクセストークンを短く保ちます。即時に効かせたい場合は、API が確認するユーザー単位の基準時刻か、jti の拒否リストを追加します。
ID トークンとアクセストークンの違いは何ですか? ID トークンは、誰がサインインしたかをクライアントに伝えるもので、クライアント向けです。アクセストークンは API 向けです。API は ID トークンを拒否すべきで、typ と aud の確認がそれを明確にします。
HS256 と RS256 のどちらを使うべきですか? HS256 は 1 つの共有シークレットを使うため、トークンを検証するサービスはどれもトークンを発行できてしまいます。RS256 や ES256 なら、API は公開鍵で検証し、秘密鍵は認可サーバーだけが持ちます。サービスが複数あるなら、こちらのほうが安全な既定の選択です。